← 문제로

15. 코루틴/파이버 기반 동시성: 스택풀 vs 스택리스, 유저모드 스케줄링

난이도 상
내 답안
모범답안

모범답안 — 코루틴/파이버 기반 동시성: 스택풀 vs 스택리스, 유저모드 스케줄링

난이도: 상

핵심 답변

  • 코루틴: 실행을 중간에 멈추고(양보, yield) 나중에 그 지점부터 재개할 수 있는 함수. OS 스레드와 달리 커널이 아니라 유저모드(라이브러리/런타임)가 스케줄링한다. 커널 개입(시스템 콜, 컨텍스트 스위치 트랩)이 없어 전환 비용이 스레드보다 훨씬 싸고(수 ns~수십 ns 대 수 µs), 스택 크기도 필요에 따라 작게 잡을 수 있어(또는 아예 힙에 상태만 저장) 메모리도 훨씬 적게 쓴다.
  • 스택풀 코루틴(파이버, boost::context, Lua 코루틴): 자신만의 완전한 호출 스택을 갖는다. 호출 깊이 어디서든(중첩 함수 안에서도) yield할 수 있다. 스위칭 시 레지스터+스택 포인터를 통째로 교체한다.
  • 스택리스 코루틴(C# async/await, C++20 co_await): 컴파일러가 함수를 상태 머신으로 변환한다. 로컬 변수는 힙(또는 컴파일러가 만든 프레임)에 저장되고, await/yield그 함수 자신의 최상위 지점에서만 가능하다(호출한 하위 함수 내부에서 임의로 양보 불가 — 그 함수도 코루틴이어야 전파된다). 스택이 없으므로 스위칭이 더 싸고 메모리도 더 절약되지만 유연성이 떨어진다.
  • 협조적 스케줄링: 코루틴은 스스로 yield하기 전까지 절대 선점되지 않는다. 그래서 "yield 지점 사이"는 사실상 원자적 임계구역이 되어 락 없이도 데이터 레이스가 없다 — 단, 하나가 무한루프에 빠지거나 yield를 잊으면 전체 스케줄러(같은 스레드의 다른 코루틴들)가 굶는다(기아).

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

스레드 vs 코루틴: 스케줄링 주체와 비용

OS 스레드 전환은 커널이 개입한다: 타이머 인터럽트나 블로킹 시스템 콜이 트랩을 걸고, 커널이 레지스터·스택 포인터·(필요시) 페이지 테이블을 저장/복원하고 스케줄러가 다음 스레드를 고른다. 이 경로는 특권 모드 전환, TLB/캐시 워밍 손실까지 겹쳐 수 µs 단위다. 또한 스레드는 기본 스택이 보통 1MB(가상 주소 공간 예약, 실제 커밋은 필요 시)라 수천 개를 만들면 가상 메모리·커밋 메모리가 급증한다.

코루틴은 유저모드 라이브러리가 레지스터 세트(스택풀이면 스택 포인터 포함)만 저장/복원하는 함수 호출 수준의 연산이라 커널 트랩이 없다. 스택풀 코루틴도 스택을 훨씬 작게(수십 KB) 잡을 수 있고, 스택리스는 필요한 로컬 변수만큼만 힙에 든다.

스택풀 vs 스택리스의 구조적 차이

  • 스택풀: 코루틴 전용 스택 위에서 보통의 함수 호출처럼 동작한다. A() → B() → C()로 깊이 들어간 상태에서 C() 안에서 yield해도 A, B의 스택 프레임이 그 스택에 그대로 남아있다가 재개 시 다시 이어진다. 스위칭은 CPU 레지스터 + 스택 포인터(SP) 교체 한 번으로 끝난다(어셈블리로 구현되는 swapcontext 류).
  • 스택리스: 컴파일러가 await/yield 지점마다 함수를 여러 조각으로 쪼개 "재개 지점 번호 + 그 시점까지 살아있는 로컬 변수"를 담은 구조체(상태 머신)를 만든다. 이 변환은 그 함수 자체에만 적용되므로, 하위 호출 함수 내부에서 멈추려면 그 함수도 코루틴/async여야 하고 호출자가 그 결과를 다시 await해야 한다("코루틴 색깔 전파", function coloring 문제). 대신 힙 할당(C#은 상태머신 클래스, 종종 박싱)이 스위칭 비용에 섞여 들어간다.

협조적 스케줄링의 대가

코루틴 사이에는 선점이 없으므로 "yield 지점부터 다음 yield 지점까지"는 임계구역처럼 안전하다 — 뮤텍스가 필요 없다(단일 스레드 위에서 도는 코루틴들 사이에서). 하지만:

  • 하나가 CPU 바운드 루프를 돌며 yield를 호출하지 않으면 같은 스레드의 다른 모든 코루틴이 정지한다(스레드 하나가 통째로 막힘).
  • 블로킹 시스템 콜(동기 파일 I/O 등)을 코루틴 안에서 그대로 부르면 스케줄러가 개입할 수 없어 스레드 전체가 블로킹된다 — 코루틴 생태계에서는 반드시 논블로킹/비동기 I/O와 짝지어야 한다.

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

시나리오 분석

  • 가독성: 콜백 기반 상태 머신은 "추적 상태에서 플레이어를 잃으면 어느 콜백으로 가는가"가 여러 함수에 흩어져 흐름을 눈으로 따라가기 어렵다. 코루틴은 Patrol() → SeePlayer 체크 → 추적 루프 → Attack()위에서 아래로 읽히는 순차 코드로 쓰면서도, 실제로는 매 틱 중단/재개되는 상태 머신과 동일하게 동작한다. 즉 "표현은 순차, 실행은 상태 머신"이라는 이점을 얻는다.
  • (A) yield 시점의 내부 동작(스택풀 가정): 현재 코루틴의 CPU 레지스터와 스택 포인터를 코루틴 제어 블록에 저장하고, 스케줄러(또는 호출자)의 저장된 레지스터/스택 포인터로 복원해 실행을 이어간다. Patrol(), MoveTowardPlayer()가 호출한 하위 함수들의 로컬 변수·리턴 주소는 이 코루틴 전용 스택 위에 그대로 남아 있어서, 다음에 이 코루틴이 다시 스케줄될 때 yield() 다음 줄부터 스택이 살아있는 채로 이어진다.
  • 메모리·전환 비용: 스레드 수천 개는 OS 스택 예약 + 커널 스레드 제어 블록 + 커널 스케줄러 개입 비용이 누적돼 수 GB 가상 메모리와 눈에 띄는 컨텍스트 스위치 오버헤드를 만든다. 코루틴/파이버는 스택을 작게(예: 8~64KB) 잡거나 스택리스면 힙에 필요한 만큼만 쓰고, 스위칭은 유저모드 레지스터 교체라 스레드 전환보다 자릿수 단위로 싸다. 수천 마리 몬스터를 "스레드당 하나"로 돌리는 것은 비현실적이지만 코루틴이라면 자연스럽다.
  • 워커 스레드 분산 시 주의점:
    1. 공유 월드 데이터: 코루틴이 협조적이라 "코루틴끼리는" 락이 필요 없어도, 서로 다른 OS 스레드에서 도는 코루틴들이 같은 월드 상태(다른 몬스터, 플레이어 목록)에 접근하면 이건 진짜 멀티스레드 레이스이므로 뮤텍스/락프리 구조/공간 분할(파티셔닝)이 여전히 필요하다.
    2. 코루틴의 스레드 어피니티: 대부분의 스택풀 코루틴 구현은 "생성된 스레드에서만 재개 가능"하거나, 다른 스레드로 옮기려면 스택/레지스터를 명시적으로 이관해야 한다. 이관을 지원하지 않는 라이브러리에서 임의로 다른 스레드 큐에 넣으면 정의되지 않은 동작이 난다. 몬스터를 워커에 배정할 때 "그 코루틴은 그 워커에 고정"하는 것이 흔한 안전한 설계다.
  • C#의 두 대안 비교:
    • IEnumerator/yield return 코루틴: 컴파일러가 만드는 상태 머신(스택리스)이라 MoveNext()를 호출하는 쪽(예: Unity의 코루틴 러너)이 매 틱 진행시켜야 한다. 임의의 하위 메서드 안에서 양보할 수 없고(yield return은 그 이터레이터 메서드 자신의 최상위에서만), 값을 몇 개 넘기는 정도의 표현력이라 복잡한 대기(여러 이벤트 조합)는 다루기 번거롭다.
    • async/await: Task 기반이라 스레드풀/동기화 컨텍스트와 얽히고, 결과를 반환하는 계산에 최적화돼 있어 "매 틱 재개되는 무한 루프형 AI"를 표현하기엔 어색하다(태스크 완료 개념과 틱 기반 반복이 잘 안 맞음). 함수 색깔 전파 문제도 동일하게 적용된다.
    • 결론: 틱 기반 AI처럼 "매 프레임 재개되는 장기 실행 루프"에는 IEnumerator 계열이 더 자연스럽고, async/await는 "한 번 완료되는 비동기 작업"(DB 저장, 네트워크 요청)에 적합하다.

흔한 오답·함정

  • "코루틴은 멀티스레드라 락이 필요 없다" → 틀렸다. 코루틴은 기본적으로 단일 스레드 위의 협조적 동시성이다. 여러 OS 스레드에 걸쳐 돌리면 스레드 간 공유 데이터에는 여전히 동기화가 필요하다.
  • "스택풀 코루틴이 스택리스보다 항상 낫다" → 스택풀은 유연하지만 스택 메모리를 미리 잡아야 하고 스위칭에 레지스터+SP 저장이 필요하다. 스택리스는 더 가볍고 컴파일러 최적화 여지가 크지만 함수 색깔 문제·힙 할당이 따른다. 상황에 따라 다르다.
  • "코루틴 안에서 블로킹 I/O를 불러도 그 코루틴만 멈춘다" → 아니다. 스레드 자체가 블로킹되므로 같은 스레드의 다른 모든 코루틴도 같이 멈춘다.
  • "yield만 있으면 데이터 레이스가 절대 없다" → 같은 스레드 안에서는 맞지만, 코루틴이 여러 스레드에 분산되거나 공유 자원을 다른 스레드의 코드가 동시에 만지면 레이스가 그대로 발생한다.

꼬리질문 대비

  1. Q: 코루틴이 "yield를 안 하면" 어떻게 되는가? 어떻게 막는가? A: 그 스레드에 묶인 다른 모든 코루틴이 굶는다(기아). 실무에서는 워치독(타임아웃 감지), 장기 루프에 주기적 yield 강제 삽입, 또는 CPU 바운드 작업은 아예 별도 스레드풀로 위임하는 방식으로 완화한다.

  2. Q: 스택풀 코루틴의 스택 크기를 잘못 잡으면 어떤 문제가 생기나? A: 너무 작으면 깊은 재귀·큰 로컬 변수에서 스택 오버플로(가드 페이지가 없으면 인접 메모리 손상, 있으면 크래시)가 나고, 너무 크게 잡으면 코루틴 수천 개일 때 메모리를 낭비한다. 가드 페이지 + 적정 크기 측정이 필요하다.

  3. Q: 코루틴과 파이버(fiber)는 같은 개념인가? A: 사실상 유사하다 — 둘 다 유저모드 협조적 스케줄링 단위다. Windows API의 "Fiber"는 스택풀 코루틴을 OS가 직접 지원하는 구체적 구현이고, "코루틴"은 더 일반적인 언어/런타임 차원의 개념(스택풀·스택리스 모두 포함)이다.

  4. Q: 게임서버에서 코루틴 대신 그냥 상태 머신(enum + switch)을 쓰면 안 되는가? A: 될 수 있고 실제로 널리 쓰인다. 코루틴은 "복잡한 순차적 흐름을 순차 코드로 표현하고 싶을 때" 가독성 이점이 크고, 단순한 상태 전이는 명시적 상태 머신이 오버헤드 없이 더 예측 가능할 수 있다. 팀 컨벤션과 로직 복잡도에 따라 선택한다.