- 높은 경합 상황에서는 Mutex 구현의 차이가 크게 드러나며, Cosmopolitan Libc의
pthread_mutex_t는 Windows와 Linux 주요 구현보다 더 짧은 실행 시간과 낮은 CPU 사용량을 보임 - Windows의 24-core Threadripper 29070WX 테스트에서 Cosmopolitan은 Microsoft SRWLOCK보다 2.75배 빠르고 CPU 자원은 18배 적게 사용함
- Linux의 96-core Threadripper Pro 7995WX에서는 glibc보다 3배, musl libc보다 11배 빠르며 CPU 시간 격차는 더 크게 벌어짐
- MacOS M2 Ultra에서는 Apple Libc가 근소하게 앞서고, Cosmopolitan은 ARM 환경에서 XNU의 ulock 시스템 호출에 의존하는 단순한 알고리듬을 사용함
- 성능의 기반은 Google의 nsync 통합이며, CAS 빠른 경로, 대기자 큐, futex/ulock/
WaitOnAddress(), 기아 방지와 designated waker 설계가 핵심임
경합 Mutex 벤치마크 방식
- 테스트는 30개 스레드를 만들고, 각 스레드가 같은 전역 정수
g_chores를 100,000번 증가시키는 방식임 - 각 증가 작업은
pthread_mutex_lock()과pthread_mutex_unlock()사이의 매우 작은 임계 구역에서 실행됨 - 측정값은 마이크로초 단위이며 세 가지 시간을 구분함
- wall time: 프로그램 실행에 걸린 실제 시간이며 스레드 생성과 join 오버헤드를 포함함
- user time: 사용자 공간에서 소비한 CPU 시간
- system time: 커널에서 소비한 CPU 시간
- 여러 스레드가 병렬로 실행되므로 user time과 system time의 합은 wall time보다 커질 수 있음
- 비경합 상황에서는 구현 간 성능 차이가 대체로 작지만, 경합 상황에서는 Mutex 설계 차이가 크게 드러남
Windows: SRWLOCK보다 빠른 Cosmopolitan
- Windows 테스트는 24-core Threadripper 29070WX에서 수행됨
- Mark Waterman의 MutexShootout은 높은 경합 시나리오에서 Windows의 SRWLOCK을 가장 강한 구현으로 평가했음
- 같은 조건에서 Cosmopolitan
pthread_mutex_t는 SRWLOCK보다 더 짧은 wall time과 더 낮은 CPU 사용량을 기록함
| 구현 | wall time | user time | system time |
|---|---|---|---|
Cosmopolitan pthread_mutex_t |
148,940µs | 328,125µs | 62,500µs |
| Microsoft SRWLOCK | 410,416µs | 5,515,625µs | 1,640,625µs |
Microsoft CRITICAL_SECTION |
949,187µs | 7,937,500µs | 5,078,125µs |
MSVC 2022 std::mutex |
991,750µs | 12,156,250µs | 4,031,250µs |
| spin lock | 1,165,435µs | 24,515,000µs | 15,000µs |
Cygwin pthread_mutex_t |
9,780,803µs | 1,937,000µs | 6,156,000µs |
- Cosmopolitan Mutex는 Microsoft SRWLOCK보다 2.75배 빠르고, CPU 자원은 18배 적게 사용함
- POSIX 구현을 Windows에 제공하는 Cygwin Mutex와 비교하면 65배 빠름
- Cygwin Mutex는 이 사용 사례에서 spin lock보다도 느린 결과를 냄
Linux: wall time보다 더 큰 CPU 시간 격차
- Linux 테스트는 96-core Threadripper Pro 7995WX에서 수행됨
| 구현 | wall time | user time | system time |
|---|---|---|---|
Cosmopolitan pthread_mutex_t |
36,905µs | 44,511µs | 23,492µs |
glibc pthread_mutex_t |
101,353µs | 150,706µs | 2,724,851µs |
| spin lock | 202,423µs | 4,694,749µs | 2,000µs |
Musl libc pthread_mutex_t |
411,013µs | 2,167,898µs | 9,926,850µs |
- Cosmopolitan Mutex는 glibc보다 3배, musl libc보다 11배 빠름
- CPU 시간 기준으로는 glibc보다 42배, musl libc보다 178배 적게 사용함
- 모든 스레드가 직렬화된 작업을 해야 하는 워크로드에서는 Cosmopolitan이
htop상 한 코어만 활성화된 것처럼 보일 수 있음 - 같은 상황에서 glibc와 musl libc는 CPU 사용량을 크게 채울 수 있어, 같은 서버에서 여러 작업을 실행할 때 부담이 커짐
MacOS: Apple Libc가 근소하게 앞섬
- MacOS 테스트는 M2 Ultra에서 수행됨
| 구현 | wall time | user time | system time |
|---|---|---|---|
| Apple Libc | 52,263µs | 43,202µs | 911,009µs |
Cosmopolitan pthread_mutex_t |
54,700µs | 63,055µs | 1,003,674µs |
- MacOS M2 ARM64에서는 Apple Libc가 Cosmopolitan Mutex보다 약간 빠름
- Cosmopolitan의 일반 Mutex 구현은 이 플랫폼에서 잘 동작하지 않음
- MacOS ARM에서 Cosmopolitan은 Ulrich Drepper의 Futexes Are Tricky에 기반한 더 단순한 알고리듬을 사용함
- 이 방식은 무거운 처리를 대부분 XNU의 ulock 시스템 호출에 맡기며, 결과적으로 Apple 구현과 거의 같은 성능을 냄
성능의 기반: nsync 통합
- Cosmopolitan Mutex 성능의 핵심은 Google의 nsync 라이브러리 통합임
- nsync는 GitHub 스타가 371개인 라이브러리이며, Google의 Mike Burrows가 작성함
- Cosmopolitan 통합 과정에서 다음 작업이 이루어짐
- nsync의 Mutex unlock 함수에서 오래 발견되지 않은 버그를 찾아 수정함
- AARCH64에서 C11 원자 연산으로 포팅해 경합 nsync Mutex를 upstream nsync보다 30% 빠르게 만듦
- futex 같은 시스템 통합을 새로 작성해 런타임 이식성을 가능하게 함
- POSIX 스레드 취소와 매끄럽게 동작하도록 만듦
nsync의 동작 방식
- nsync는 잠금을 빠르게 얻기 위해 처음에 낙관적 CAS(compare and swap) 를 즉시 시도함
- 잠금을 얻지 못하면 호출 스레드를 대기자들의 이중 연결 리스트에 추가함
- 각 대기자는 별도 독립 캐시라인에 자기 세마포어를 가짐
- 대기 상태에 들어간 스레드는 더 이상 주 잠금을 건드리지 않음
- 여러 코어가 같은 캐시라인을 건드릴 때 생기는 통신 오버헤드를 줄이는 데 중요함
- 관련 배경으로 Ulrich Drepper의 What Every Programmer Should Know About Memory가 연결됨
- nsync는 운영체제의 futex를 사용해 스레드를 잠들게 함
- MacOS에서는 futex가 ulock으로 불림
- Windows에서는
WaitOnAddress()가 futex 역할을 함 - Cosmo가 지원하는 OS 중 NetBSD만 futex가 없으며, POSIX 세마포어를 커널 공간에서 구현하고 각 세마포어마다 새 파일 디스크립터가 필요함
- nsync는 “long wait” 개념으로 기아(starvation) 를 피함
- 대기자가 30번 깨어났지만 매번 내부적으로 잠금 획득에 실패하면, 아직 기다리지 않은 스레드가 잠금을 얻지 못하도록 잠금에 비트를 추가함
- 이 비트가 있으면 대기열이 어느 정도 비워질 때까지 새로 진입한 스레드의 초기 CAS가 실패함
- 작은 임계 구역에서 경합하는 사용 사례는 designated waker 개념으로 빨라짐
- 어떤 스레드가 깨어서 잠금을 얻으려 할 때 주 잠금에 비트가 설정됨
- nsync에서는 unlock 함수가 다음 대기 스레드를 깨우는 책임을 가짐
- 이 비트 덕분에 unlock 중인 스레드는 이미 깨어 있는 스레드가 있을 때 두 번째 대기자를 깨우지 않아도 됨
- 관련 소스 코드는
cosmopolitan/third_party/nsync/mu.c와cosmopolitan/libc/intrin/pthread_mutex_lock.c에 있음
실제 서비스와 검증 코드
- Cosmo Mutex를 사용한 라이브 데모로 http://ipv4.games/ 서버를 볼 수 있음
- 이 서비스는 2코어 GCE VM에서 실행되며, 지금까지 최대 49,131,669개 IP 규모의 봇넷 DDoS를 견뎠음
- nsync 덕분에 SQL 쿼리를 백그라운드 스레드로 옮기고, 스레드들이 서로 메시지를 보내는 구조를 사용할 수 있었음
- 상태 지표는 /statusz에서 확인 가능함
- 벤치마크 코드는
gettimeofday()로 wall time을 재고,getrusage()로 user time과 system time을 측정함 - 마지막에는
g_chores == THREADS * ITERATIONS를 확인해 모든 증가 작업이 수행됐는지 검증함
Spin lock을 볼 때의 주의점
- 비경합 상황에서는 Mutex 구현 간 차이가 작고, 몇 줄짜리 spin lock이 더 나을 수도 있음
- 하지만 spin lock은 정말 다른 선택지가 없을 때만 사용해야 함
- 커널처럼 극도로 낮은 수준의 제약 때문에 더 복잡한 방식을 쓰기 어려운 곳에서는 유용함
- nsync lock 내부 구현 세부 사항으로도 spin lock이 쓰일 수 있음
- lock 성능을 wall time만으로 보면 spin lock이 좋아 보일 수 있으므로,
getrusage()로 CPU 시간까지 함께 확인해야 함