Linux gaming이 Windows를 이긴 진짜 이유 – ntsync가 커널에 들어가다

목차

Before — Wine에서 게임 하나를 띄우면 동기화 호출 한 번에 wineserver로 IPC를 던지고, 컨텍스트 스위치 두세 번을 거쳐 응답을 받았다. 초당 수만 번 일어나는 일이다. 결과는 끊김과 stutter, 그리고 1% low FPS의 붕괴다.

After — 같은 게임을 같은 하드웨어에서 Linux 6.14 이상 커널로 돌리면, 같은 호출이 ioctl() 한 번에 끝난다. wineserver는 더 이상 핫패스에 끼어들지 않는다. Phoronix에 올라온 벤치마크 기준으로 일부 타이틀이 Windows 네이티브 프레임을 따라잡거나 추월했다(출처: Phoronix, 2025년 ntsync 비교 기사).

특히, 이 글은 이 두 상태 사이에서 무슨 일이 일어났는지를 추적한다. Windows API가 Linux 커널 기능으로 흡수된 사건, 즉 ntsync 드라이버 이야기다. 마케팅 문구 같지만 코드를 보면 의외로 단순한 사실이다.

문제 정의 — Wine은 왜 항상 동기화에서 막혔나

Windows 게임은 NT 커널의 동기화 객체를 굉장히 자주 호출한다. CreateEventW, WaitForSingleObject, WaitForMultipleObjects, ReleaseSemaphore 같은 것들이다. 멀티스레드 게임 엔진이 GPU/CPU 사이를 조율하려면 이 호출이 초당 수만~수십만 번 일어난다.

그래서, Wine은 이걸 흉내내기 위해 wineserver라는 별도 프로세스를 둔다. 이 프로세스가 NT 동기화 상태를 보관하고, 게임 프로세스가 RPC로 요청을 던지면 응답을 돌려준다. 정확성은 보장된다. 다만 비싸다. 매 호출마다 최소 두 번의 컨텍스트 스위치가 일어나고, L1 캐시는 깨지고, 스케줄러는 흔들린다.

따라서, 게임처럼 latency에 민감한 워크로드에서 이건 치명적이다. CPU-bound 구간에서 1% low FPS가 깎이는 주범이 여기였다.

NT 동기화 객체의 의미적 차이

즉, 핵심은 "그냥 mutex로 바꾸면 되지 않나"가 아니다. NT 동기화 객체는 POSIX 프리미티브와 의미가 다르다.

  • NT mutant는 재귀(recursive) 락이고 소유자 추적을 한다. 소유자가 비정상 종료하면 객체가 abandoned 상태로 전환된다.
  • NT event는 manual-reset/auto-reset 두 종류가 있고, signal 시 깨우는 스레드 수가 다르다.
  • WaitForMultipleObjects는 N개 객체 중 하나가 시그널되면 깨는데, 이 N개가 mixed type일 수 있다. mutex + event + semaphore를 한 번에 기다린다. WAIT_ALL 모드면 전부 만족할 때까지 대기한다.

실제로, POSIX의 pthread_mutexsem_t로는 이걸 그대로 매핑할 수가 없다. futex도 마찬가지였다. 의미 손실 없이 N-타입 wait을 제공하는 syscall이 오랫동안 존재하지 않았다.

기존 접근 — esync와 fsync의 우회

예를 들어, ntsync 이전에도 시도는 있었다. 두 가지가 유명하다.

그러나, esync는 eventfd를 NT event 하나당 하나씩 만들고, epoll로 다중 wait을 구현했다. 작동은 했다. 게임이 수만 개의 NT 객체를 만드는 순간 파일 디스크립터 한계에 부딪혔다. ulimit -n을 65535 이상으로 올려야 했고, 그래도 일부 타이틀은 fd가 모자라 죽었다.

결국, fsync는 futex2 시리즈(나중에 futex_waitv로 정리된 패치)를 활용한다. Linux 5.16에서 futex_waitv syscall이 들어가면서 멀티 객체 wait이 가능해졌다(출처: Linux 5.16 release notes, 2022-01). fsync는 esync보다 fd를 적게 쓰고 빨랐다. 여전히 wineserver와 일부 상태를 공유해야 했고, mutant의 재귀/소유자 시맨틱을 완전히 재현하지는 못했다.

# esync 사용 시 흔히 부딪힌 에러
$ wine game.exe
0009:err:eventfd: failed to create eventfd: Too many open files
0009:err:esync: out of file descriptors, falling back to wineserver

# 임시 우회 — fd 한계 끌어올리기
$ ulimit -n 524288
$ WINEESYNC=1 wine game.exe

특히, 위 에러를 본 사람이 많을 것이다. esync의 한계는 명확했다.

두 방식과 ntsync의 비교

방식 멀티 wait mutant 재귀 추가 의존성 핫패스 IPC
wineserver only O O 없음 매 호출
esync O (epoll) 부분 높은 fd 한계 일부
fsync O (futex_waitv) 부분 커널 5.16+ 일부
ntsync O (ioctl) O 커널 6.14+ 거의 없음

한편, 핵심은 "wineserver를 핫패스에서 빼낼 수 있느냐"였다. ntsync 이전엔 부분적으로만 가능했고, 셋업 비용(높은 fd 한계, 커스텀 커널 패치)이 늘 따라붙었다.

제안 — ntsync, Windows API가 커널이 되다

ntsync는 CodeWeavers의 Elizabeth Figura가 주도해 만든 캐릭터 디바이스 드라이버다. NT 동기화 객체를 Linux 커널 내부에 직접 구현한다. 이름 그대로 "NT synchronization"이다.

Linux 6.14에서 staging을 거치지 않고 메인라인에 들어갔다(출처: Linux Kernel Mailing List 머지 커밋, 2025년 초). 다시 말하면, Windows API의 일부가 사실상 Linux 커널 기능이 됐다. 호스트 OS가 게스트 OS의 의미론을 흡수한 보기 드문 사례다.

또한, 작동 방식은 단순하다. /dev/ntsync를 열고, ioctl로 NT 객체(이벤트/뮤턴트/세마포어)를 만든 뒤, wait/release를 역시 ioctl로 호출한다. 모든 상태는 커널이 가진다. wineserver는 객체 생성/소멸 같은 cold path에만 관여한다.

// 의사 코드 — ntsync 사용 흐름
int fd = open("/dev/ntsync", O_RDWR | O_CLOEXEC);

// NT auto-reset event 생성
struct ntsync_event_args ev = {
    .manual = 0,        // auto-reset 모드
    .signaled = 0,      // 초기 비시그널 상태
};
int event_fd = ioctl(fd, NTSYNC_IOC_CREATE_EVENT, &ev);

// 여러 객체를 한 번에 대기 (WaitForMultipleObjects 대응)
struct ntsync_wait_args wait = {
    .timeout = UINT64_MAX,
    .count = 3,
    .objs = (uintptr_t)obj_fds,   // event/mutant/semaphore 혼합 가능
    .owner = thread_id,           // mutant 소유자 추적용
    .index = -1,
};
ioctl(fd, NTSYNC_IOC_WAIT_ANY, &wait);
// wait.index에 시그널된 객체의 인덱스가 들어온다

WaitForMultipleObjects 한 번이 ioctl 한 번으로 끝난다. 컨텍스트 스위치는 최대 두 번(유저→커널→유저). wineserver IPC가 들어가던 자리에 직선 syscall이 들어왔다.

왜 이게 빠른가

세 가지 이유가 있다.

게다가, 첫째, 큐잉이 커널 내부에서 일어난다. 유저 공간에 상태를 노출하지 않아도 되니 lock contention이 줄어든다. esync처럼 eventfd 다발을 들고 다닐 필요가 없다.

둘째, wakeup이 정확하다. epoll을 거치지 않고 곧바로 wait큐에서 깨운다. spurious wakeup이 적고, wake-up to run latency가 짧다. 스케줄러가 작은 단위 wakeup에 흔들리지 않는다.

또한, 셋째, mutant 시맨틱을 처음으로 손실 없이 재현한다. 소유자 죽으면 abandoned 상태로 전환되는 부분까지 커널이 들고 있다. Wine은 이걸 흉내내기 위한 유저 공간 코드를 들어내고 ioctl 한 줄로 위임한다. 코드가 줄었다는 건 버그 표면적이 줄었다는 뜻이기도 하다.

검증 — 벤치마크가 말하는 것

실제로, Phoronix가 ntsync 머지 직후 진행한 비교 테스트를 보면, CPU-bound 구간이 큰 타이틀에서 fsync 대비 평균 프레임이 의미 있게 올랐다(출처: Phoronix, 2025년 ntsync benchmark 기사). 1% low FPS는 더 크게 개선됐다. 이 지표는 사람이 체감하는 끊김과 직결된다.

실제로, 직접 재현해본다면 대략 이런 흐름으로 측정한다.

# 1) ntsync 모듈 로드 확인
$ lsmod | grep ntsync
ntsync                 24576  0

# 2) 권한 부여 (udev 룰)
$ cat /etc/udev/rules.d/70-ntsync.rules
KERNEL=="ntsync", MODE="0660", TAG+="uaccess"

# 3) Proton에서 강제 활성화 — Steam 게임 실행 옵션
$ PROTON_USE_NTSYNC=1 %command%

# 4) syscall 분포 측정
$ perf trace -s -p $(pidof game.exe) -- sleep 30

게다가, 가장 효과가 큰 워크로드는 "스레드가 많고 동기화가 잦은 게임"이다. 오픈월드 RPG, 시뮬레이션, 일부 RTS가 여기 해당한다. 반대로 GPU-bound가 명확한 경우는 차이가 거의 없다. 동기화가 병목이 아니기 때문이다.

측정 시 흔히 잡히는 함정

ntsync만 켜고 Proton 버전을 옛것으로 두면 효과가 제한된다. Proton 9 이후의 Wine은 ntsync 경로를 우선 타도록 패치돼 있다(출처: ValveSoftware/Proton GitHub Release Notes, Proton 9.x 트랙). Wine 버전과 커널 버전이 동시에 맞아야 한다는 점은 자주 놓치는 부분이다.

또 하나, CPU 거버너를 powersave로 두면 ntsync의 효과가 묻힌다. 작은 wakeup에서 CPU 주파수가 안 따라온다. schedutil이나 performance로 두고 비교해야 의미가 있다. 이건 실제로 한참을 헤맨 시간을 벌어준다. 벤치마크 그래프가 이상하게 평탄하면 거버너부터 확인하라.

특히, :::tip ntsync 효과를 확인하는 빠른 방법은 perf trace -s -p $(pidof game.exe)로 syscall 분포를 찍어보는 것이다. ntsync가 제대로 붙었다면 ioctl 비중이 크게 늘고 futex, read/write(eventfd 경로) 비중이 줄어 있어야 한다. 분포가 안 바뀌면 Proton이 ntsync 경로를 못 잡은 것이다. :::

DXVK와 VKD3D — 평행 진화한 그래픽 API 레이어

ntsync가 CPU 측 동기화 문제를 풀었다면, 그래픽 측에선 DXVKVKD3D-Proton이 비슷한 일을 했다. DirectX 9/10/11은 DXVK가, DirectX 12는 VKD3D-Proton이 Vulkan으로 옮긴다.

예를 들어, 흥미로운 부분은 여기다. Vulkan은 D3D12보다 명시적인 API다. 그래서 일부 워크로드에서 변환 레이어가 오히려 더 효율적인 명령 큐를 만든다. 디스크립터 인덱싱, 멀티스레드 커맨드 버퍼 생성 같은 영역이 그렇다. "추상화 위에 추상화"가 무조건 느린 게 아니라는 사례로 보인다.

// VKD3D-Proton이 D3D12 → Vulkan으로 옮길 때 흔히 하는 일
// (단순화한 의사 코드)

// 게임 측 D3D12 펜스 대기
fence->SetEventOnCompletion(value, hEvent);
WaitForSingleObject(hEvent, INFINITE);
// ↑ 위 호출이 ntsync 경로로 빠진다 — 핫패스의 핵심

// Vulkan 측: timeline semaphore 한 번으로 끝
VkSemaphoreWaitInfo wait = {
    .sType = VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO,
    .semaphoreCount = 1,
    .pSemaphores = &timelineSem,
    .pValues = &value,
};
vkWaitSemaphores(device, &wait, UINT64_MAX);

D3D12 펜스가 Win32 이벤트와 묶이는 구조 때문에, 이 부분도 CPU 측 ntsync 효과를 그대로 받는다. 그래픽 동기화와 CPU 동기화가 같은 경로로 모인다는 게 핵심이다. Vulkan timeline semaphore + ntsync event 조합은 Windows의 fence + event 조합보다 syscall 횟수가 적게 잡힌다.

이건 단순히 "Wine이 잘 해서"가 아니라 Vulkan 자체의 설계 결정이 만들어준 여유다. 그 여유를 ntsync가 마지막에 정리해준 셈이다.

한계점 — 아직 풀리지 않은 문제들

ntsync로 모든 게 풀린 것은 아니다. 몇 가지는 분명히 짚어둘 필요가 있다.

첫째, EAC와 BattlEye 같은 안티치트다. 커널 모드 안티치트는 Linux에서 작동하지 않거나 제한적이다. Easy Anti-Cheat는 Proton EAC 런타임이 있지만 개발사가 명시적으로 활성화해야 한다(출처: Epic EAC for Linux 문서). 결국 멀티플레이 일부 타이틀은 여전히 막혀 있다.

둘째, DRM/저작권 보호가 강한 일부 타이틀은 Windows 전용 API에 깊게 결합돼 있어서 Wine 측 패치 외에 ntsync로 풀 수 없다.

셋째, 드라이버 격차. NVIDIA는 오픈 커널 모듈 전환과 함께 Wayland 호환성이 많이 좋아졌지만, 일부 워크로드에서 Mesa/RADV(AMD) 대비 변환 오버헤드가 있는 것으로 보인다. 이건 ntsync와 별개 문제다.

즉, 넷째, 머지됐다고 모든 디스트로가 즉시 쓰는 건 아니다. Linux 6.14 이상 커널을 깔거나, 백포트된 커널을 쓰는 디스트로(예: Bazzite, CachyOS)를 골라야 한다. Ubuntu LTS처럼 보수적인 디스트로는 시간이 더 걸린다.

다음에 볼 만한 지점

  • ntsync의 NT object 라이프사이클을 Wine과 어떻게 나눠 가지는지 — Wine의 server/protocol.h 변경 이력을 추적하면 보인다.
  • futex2 후속 RFC들과 ntsync의 의미적 차이. 일부 기능은 중복돼 있고 한쪽으로 수렴할 가능성이 있다고 본다.

특히, —

개인적으로는 "에뮬레이션이 네이티브를 이긴다"가 아니라 "에뮬레이션을 빨리 돌리기 위해 호스트 OS가 게스트 의미론을 흡수했다"가 더 정확한 표현인 것 같다. ntsync는 우아한 해킹이 아니라 의도된 흡수다.

당장 해볼 수 있는 건 세 가지다. (1) uname -r로 커널이 6.14 이상인지 확인한다. (2) Proton Experimental에서 PROTON_USE_NTSYNC=1로 한 타이틀을 테스트한다. (3) perf trace로 syscall 분포가 ioctl 쪽으로 옮겨갔는지 확인한다.

관련 글