먼저 아래와 같이 각 단위테스트마다 do_something 함수를 호출하여 일반 코드와 유사한 구조를 가지도록 하였다. 참고로 일반 상황에서는 간헐적으로 thread가 blocking 되거나 wait/sleep 상태에 돌입할 수 있는데, 이 테스트에서는 배제하였다. Lock-Free 로직을 통한 별렬성을 최대한 발휘할 수 있도록 고려한 것이다.
LONG gSomeData[1024];
static void do_something() {
for (int i = 0; i < 1000; i ++) {
gSomeData[i] += 1;
}
}
DWORD WINAPI test_lock_free(LPVOID param) {
int cntTest = (int)param;
for (int i = 0; i < TEST_LOOP; i++) {
volatile LONG* p = gSharedData;
for (int j = 0; j < cntTest; j++) {
::InterlockedIncrement(p);
p++;
}
do_something();
}
return 0;
}
또한, SpinLock 의 lock() 함수는 아래와 같이 단순화하였고,
void lock() { int cnt = cntSpin; while (!tryLock()) { if (-- cnt <= 0) { Sleep(1); cnt = cntSpin; } } }
SlowSpinLock 과 FastSpinLock 은 각각 아래와 같이 초기화하였다.
static SpinLock gSlowSpinLock(20); // cntSpin=20
static SpinLock gFastSpinLock(20000); // cntSpin=20000
테스트 결과는 아래와 같다.
=========== Threads = 1, Test loop = 640*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
1 359 374 374 359
2 375 374 359 374
3 375 358 375 374
4 375 374 359 374
5 390 375 374 374
=========== Threads = 2, Test loop = 320*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
1 250 359 359 265
2 265 328 327 265
3 266 358 375 265
4 281 359 343 265
5 281 327 359 281
=========== Threads = 4, Test loop = 160*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
1 234 358 359 219
2 249 343 359 203
3 265 343 328 203
4 249 359 359 218
5 250 359 359 202
=========== Threads = 8, Test loop = 80*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
1 219 343 343 219
2 187 343 328 218
3 187 343 344 218
4 203 343 297 218
5 187 343 328 234
=========== Threads = 16, Test loop = 40*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
1 187 312 297 218
2 203 312 265 218
3 203 312 265 219
4 203 296 281 218
5 187 312 281 234
=========== Threads = 32, Test loop = 20*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
1 203 296 250 234
2 187 312 250 218
3 203 296 234 219
4 203 280 250 218
5 203 297 234 234
=========== Threads = 64, Test loop = 10*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
1 202 390 359 234
2 203 452 359 250
3 218 328 312 234
4 203 343 312 234
5 218 375 343 312
결론적으로 일반적인 상황에서 lock-free 와 critical-section 간의 차이는 거의 없으며, spin_lock의 효율성은 크지 않는 것으로 보인다.
한 편, 자바 코드의 실행결과는 어떻게 될까? 실은 그 의외의 결과 때문에 이 글을 올리게 되었다. 보시다시피 모든 실행결과가 C로 작성된 테스트코드보다 빠르다.
아래는 Linux 상에서 실행한 Java 코드 수행 결과이다. 굳이 리눅스 상에서 테스트를 한 이유는 pthread_mutex 테스트 결과가 매우 저조해서, 이를 내부적으로 사용하는 자바의 퍼포먼스를 직접 검증하기 위한 것이다. 편이를 위해 옆에 pthread_mutex 테스트 값을 추가하였다.
=========== Threads = 1, Test loop = 640*1000 ===============
atomic_update = 1 : java = 214 pthread = 1411
atomic_update = 2 : java = 214 pthread = 1402
atomic_update = 3 : java = 214 pthread = 1407
atomic_update = 4 : java = 216 pthread = 1402
atomic_update = 5 : java = 216 pthread = 1406
=========== Threads = 2, Test loop = 320*1000 ===============
atomic_update = 1 : java = 212 pthread = 2015
atomic_update = 2 : java = 214 pthread = 2176
atomic_update = 3 : java = 214 pthread = 1983
atomic_update = 4 : java = 213 pthread = 2079
atomic_update = 5 : java = 216 pthread = 2143
=========== Threads = 4, Test loop = 160*1000 ===============
atomic_update = 1 : java = 160 pthread = 2437
atomic_update = 2 : java = 172 pthread = 2455
atomic_update = 3 : java = 166 pthread = 2404
atomic_update = 4 : java = 172 pthread = 2415
atomic_update = 5 : java = 173 pthread = 2347
=========== Threads = 8, Test loop = 80*1000 ================
atomic_update = 1 : java = 158 pthread = 2498
atomic_update = 2 : java = 159 pthread = 2564
atomic_update = 3 : java = 153 pthread = 2518
atomic_update = 4 : java = 160 pthread = 2526
atomic_update = 5 : java = 156 pthread = 2438
=========== Threads = 16, Test loop = 40*1000 ===============
atomic_update = 1 : java = 169 pthread = 2634
atomic_update = 2 : java = 153 pthread = 2584
atomic_update = 3 : java = 159 pthread = 2543
atomic_update = 4 : java = 164 pthread = 2550
atomic_update = 5 : java = 161 pthread = 2536
=========== Threads = 32, Test loop = 20*1000 ===============
atomic_update = 1 : java = 164 pthread = 2650
atomic_update = 2 : java = 158 pthread = 2627
atomic_update = 3 : java = 163 pthread = 2483
atomic_update = 4 : java = 161 pthread = 2469
atomic_update = 5 : java = 163 pthread = 2535
=========== Threads = 64, Test loop = 10*1000 ===============
atomic_update = 1 : java = 164 pthread = 2668
atomic_update = 2 : java = 166 pthread = 2651
atomic_update = 3 : java = 164 pthread = 2711
atomic_update = 4 : java = 167 pthread = 2780
atomic_update = 5 : java = 163 pthread = 2741
테스트 결과, do_something 함수의 내부 루프가 100이하이고, 쓰레드 수가 32개 이하인 경우, 위에 사용된 spin-lock 코드가 가장 빠르며, 200~500 사이는 lock-free 가 가장 빠르다.
자바 코드는 내부 루프가 50 인 경우에도 쓰레드 수가 64개 이상이면 가장 빠르다. 이후 내부 루프가 800 이상이 되면 거의 모든 테스트 항목에서 Java 코드 수행 속도가 가장 빨라진다.
이는 단순히 Sleep()을 사용한 위의 spin-lock 코드보다, 자바 모니터의 락 검사 메카니즘과 Thread-scheduling이 훨씬 더 효율적임을 의미한다.
굳이 쓰레드간 충돌에 의해 스케쥴링이 발생해선 안되는 상황, 즉 병렬컴퓨팅이 필요한 상황이 아니더라도 변수 한 두개를 InterlockedXxx 함수를 써서 변경하는 것은 속도 여부를 떠나, 구현과 사후관리를 위해서도 권장할만한 사항일 것이다.
다만 그 이상의 복잡한 상황이라면 일단은 주저없이 CriticalSection과 java synchronized 를 쓰는 것이 바람직하다. 실행환경이 한정되고 고정적이지 않는 한, 시스템이 제공하는 동기화 API 이상의 퍼포먼스를 내는 것은 매우 어려울 뿐더러, 안정성도 심하게 떨어뜨린다. 관련 최적화는 시스템이 매우 안정화된 상태로 미루는 것이 맞을 것이다.
멀티쓰레드 쓰레드 코딩 시 가장 중요한 두 가지 이슈는 퍼포먼스와 안정성이라 하겠다. 위 테스트 결과가 관련 시스템 설계 및 구현 시에 도움이 되기를 바란다.
댓글 없음:
댓글 쓰기