← 문제로

5. 컨텍스트 스위칭, 캐시, false sharing

난이도 중
내 답안
모범답안

모범답안 — 컨텍스트 스위칭, 캐시, false sharing

난이도: 중

핵심 답변

  • 컨텍스트 스위칭: CPU가 실행 중인 스레드를 멈추고 다른 스레드로 갈아타는 일. 직접 비용은 레지스터/PC/스택 포인터 저장·복원과 스케줄러 코드 실행, 커널 진입(syscall/인터럽트). 간접 비용(보통 더 큼)은 캐시·TLB가 새 스레드의 데이터로 식어(cold) 이후 메모리 접근이 느려지는 것.
  • 캐시 라인: CPU 캐시가 메모리를 옮기는 최소 단위(대개 64바이트). CPU는 1바이트를 읽어도 그 바이트가 든 64바이트 라인 통째를 캐시에 올린다(공간 지역성 활용). 멀티코어에서는 MESI 같은 캐시 일관성 프로토콜이 각 코어 캐시 사본의 상태(Modified/Exclusive/Shared/Invalid)를 추적해, 한 코어가 라인에 쓰면 다른 코어의 사본을 무효화(invalidate)한다.
  • false sharing: 논리적으로는 독립적인 변수들이 같은 캐시 라인에 들어 있어, 서로 다른 코어가 각자의 변수를 쓰는데도 같은 라인을 공유하게 되는 현상. 한 코어가 쓸 때마다 다른 코어의 라인 사본이 무효화되어 라인이 코어 사이를 핑퐁(ping-pong)하며 캐시 미스가 폭증한다. 진단은 프로파일러(캐시 미스/HITM 이벤트), 해결은 패딩/정렬로 변수를 서로 다른 라인에 분리.

깊이 있는 설명 (메커니즘, 왜)

컨텍스트 스위칭의 진짜 비용

직접 비용(레지스터 저장/복원 + 스케줄러)은 보통 수백 ns~수 µs다. 하지만 진짜 타격은 간접 비용이다.

  • 새 스레드는 캐시에 자기 데이터가 없어 콜드 미스가 잇따른다(워킹셋 재적재).
  • TLB(가상→물리 주소 변환 캐시)도 식는다. 특히 프로세스 간 스위칭은 주소공간이 바뀌어 TLB를 더 크게 날린다. 그래서 스레드를 코어 수보다 훨씬 많이 만들어 끊임없이 스위칭하면, 일은 안 늘고 스위칭/캐시 미스만 늘어 처리량이 떨어진다(thrashing). 게임서버가 "스레드를 코어 수에 맞춰 적게, 핀(affinity)으로 고정"하는 이유.

캐시 라인과 MESI 핑퐁

캐시는 라인(64B) 단위라, 한 라인 안의 어느 바이트든 누가 쓰면 라인 전체 상태가 바뀐다. MESI에서 코어 A가 라인에 쓰려면 그 라인을 Modified로 만들어야 하고, 이를 위해 다른 코어의 사본을 모두 Invalid로 만든다(invalidation 메시지). 코어 B가 다시 그 라인의 자기 변수를 건드리면 B는 무효화된 라인을 다시 가져와야(A의 캐시→B, cache-to-cache transfer) 한다. 이 왕복이 false sharing의 본질이다.

시나리오가 느린 이유 = false sharing

long long packetCount[8]은 8 × 8바이트 = 64바이트로, 배열 전체가 한 캐시 라인에 딱 들어간다. 워커 0이 packetCount[0]을 쓰면 그 라인이 Modified가 되며 워커 1~7의 캐시에서 같은 라인이 무효화된다. 워커 1이 packetCount[1]을 쓰려는 순간 라인을 다시 끌어와야 한다. 결국 8개 코어가 하나의 라인을 서로 빼앗으며 초당 수십만 번 핑퐁한다. 락은 없지만 하드웨어 레벨에서 사실상 직렬화 + 캐시 미스 폭탄이라, 코어를 늘릴수록 더 느려진다.

고치는 법: 라인 분리(패딩/정렬)

각 카운터를 독립된 캐시 라인에 두면 무효화가 서로 영향을 안 준다.

struct alignas(64) PaddedCounter {
    long long value;
    char pad[64 - sizeof(long long)];  // 라인을 카운터 하나로 채움
};
PaddedCounter g_stats[8];  // 각 원소가 자기 라인 차지

void onPacket(int i) { g_stats[i].value++; }

C++17이면 std::hardware_destructive_interference_size(보통 64)를 정렬 기준으로 쓰는 게 이식적이다. 더 깔끔한 대안은 아예 스레드 로컬(thread_local) 카운터를 두는 것 — 각 스레드의 변수가 다른 객체라 같은 라인에 묶일 일이 없고 캐시도 핫하게 유지된다.

합산 시점

핫 패스(매 패킷)에서 합산하면 공유 접근이 다시 생긴다. 워커별 로컬 카운터에 락 없이 누적하고, 합산은 저빈도 시점(초당 1회 통계 틱, 또는 종료 시)에 메인 스레드가 8칸을 읽어 더한다. 읽는 쪽이 정확한 원자성을 원하면 각 칸을 atomic으로 두고 load(relaxed)로 모은다(통계는 약간의 근사 허용 가능하므로 relaxed로 충분).

응용/실무 연결 (게임서버에서)

  • per-thread 통계/풀: 패킷 카운터, 메모리 풀, 난수 상태 등은 스레드 로컬로 두고 주기적으로 머지한다. false sharing 회피 + 락 제거를 한 번에 얻는다.
  • 큐 인덱스 정렬: SPSC/MPSC 큐의 head/tail 인덱스를 같은 구조체에 붙여 두면 생산자(tail 쓰기)와 소비자(head 쓰기)가 한 라인을 핑퐁한다. head와 tail을 각각 별도 캐시 라인에 정렬(alignas(64))하는 것이 lock-free 큐의 기본 최적화다.
  • 스레드 수 = 코어 수: 워커를 코어 수에 맞추고 affinity로 고정해 컨텍스트 스위칭과 캐시 콜드를 줄인다. I/O 대기가 많은 부분만 별도 풀로 분리.

흔한 오답·함정

  • "락이 없으면 캐시 문제도 없다" → false sharing은 락과 무관하게 캐시 라인 공유만으로 발생한다.
  • "배열 칸이 다르면 독립이다" → 논리적으로는 독립이지만 물리적으로 같은 라인이면 하드웨어가 함께 무효화한다.
  • "변수 사이에 패딩을 조금만 넣으면 된다" → 핵심은 캐시 라인 경계(64B) 정렬이다. 어중간한 패딩은 여전히 일부 라인을 공유한다. alignas(64)로 라인 시작에 정렬해야 한다.
  • "컨텍스트 스위칭 비용 = 레지스터 저장뿐" → 캐시/TLB 콜드라는 간접 비용이 보통 더 크다.
  • "스레드를 많이 만들수록 빠르다" → 코어 수를 넘으면 스위칭·캐시 미스만 늘어 느려진다.

꼬리질문 대비

  1. Q: true sharing과 false sharing의 차이는? A: true sharing은 같은 변수를 여러 스레드가 실제로 공유해 생기는 정당한 동기화 트래픽. false sharing은 다른 변수가 우연히 같은 라인에 묶여 생기는, 논리적으로 불필요한 트래픽. true는 알고리즘으로 줄이고, false는 레이아웃(패딩/정렬)으로 없앤다.

  2. Q: false sharing을 어떻게 의심·확인하나? A: "락도 없는데 스레드를 늘릴수록 느려진다"가 신호. perf c2c(cache-to-cache), Intel VTune의 HITM/캐시 라인 보고서로 어느 라인이 코어 사이를 오가는지 확인한다. 의심 변수에 패딩을 넣어 전후 성능을 비교하는 실험도 빠른 진단법.

  3. Q: 패딩으로 false sharing을 없애면 비용은 없나? A: 메모리 사용이 늘고(라인당 변수 하나), 캐시 용량 효율이 떨어진다. 핫한 핑퐁 변수에만 선택적으로 적용해야 한다. 읽기 전용이거나 거의 안 바뀌는 변수는 같은 라인에 묶여도 무효화가 없어 패딩이 불필요하다.