글 작성자: cjwoov
반응형

 서버 개발을 하다보면 필수적으로 알아야하고 단골 문제로 등장하는 손님이 있다.

바로 컨텍스트 스위칭(context switching)인데, 잊어버리지 않도록 해당 포스팅에서 완벽하게 정리하려고 한다.

 


1. 컨텍스트 스위칭?

 

시분할 시스템

 컨텍스트 스위칭의 개념은 멀티태스킹(Multitasking)을 구현하기 위해 탄생했다.

초기 컴퓨터는 한 번에 하나의 작업(프로세스)만 처리할 수 있었는데,

CPU의 처리 속도는 I/O(디스크 읽기, 네트워크 수신 등) 속도보다 압도적으로 빨랐다.

 

CPU가 I/O 작업을 요청하고 응답을 기다리는 동안, CPU는 말 그대로 '아무 일도 하지 않고' 놀게 되었다.

이러한 비효율을 해결하기 위해 시분할 시스템(Time-Sharing System)이 고안되었다.

 

CPU의 시간을 매우 잘게 쪼개어(Time Slice), 여러 작업(프로세스)에게 번갈아 가며 할당하는 방식.

 

1. A라는 작업이 I/O를 기다리며 멈춰있을 때, CPU가 B라는 작업을 처리한다.

2. B 작업의 할당 시간이 끝나면 C 작업을 처리한다.

 

이 과정이 워낙 빠르게 일어나기 때문에, 사용자는 마치 여러 작업이 '동시에' 실행되는 것처럼 느끼게 된다.

 

여기서 A 작업을 멈추고 B 작업을 실행하기 위해 필요한 핵심 메커니즘이 바로 컨텍스트 스위칭이다.

A가 어디까지 작업했는지 상태를 저장해두고,

B가 이전에 작업했던 상태를 불러와야만 연속적인 작업이 가능하기 때문이다.

 

 

개념

 컨텍스트는 CPU가 현재 작업을 수행하기 위해 필요한 모든 정보를 의미한다.

즉, 작업을 멈췄다가 나중에 다시 시작할 때 필요한 '상태 값'이다.

Context

  • 1. CPU 실행 상태
    • Program Counter (PC): "어디까지 실행했는가?"
      • 다음에 실행해야 할 기계어 명령어의 메모리 주소.
    • Stack Pointer (SP): "현재 함수/지역 변수는 어디에 있는가?"
      • 이 태스크가 사용하는 스택 메모리의 최상단 주소.
    • 범용 레지스터 (EAX, EBX, R1, R2...): "무엇을 계산하고 있었는가?"
      • 연산에 사용 중이던 임시 데이터, 중간 결괏값.
    • 상태 레지스터 (Flags): "방금 연산 결과는 어땠는가?"
      • 비교 결과 (e.g., Zero Flag), 오버플로우 여부 (e.g., Carry Flag) 등.
    2. 메모리 관리 정보 (프로세스 스위칭 시 필수)
    • 페이지 테이블 포인터 (e.g., CR3 레지스터): "이 태스크의 '메모리 지도'는 어디에 있는가?"
      • 가상 메모리 주소를 물리 메모리 주소로 변환하는 데 사용되는 맵(Page Table)의 위치.
    • (기타 세그먼트 레지스터 등)
    3. OS 관리 정보
    • 태스크 상태 (State): "현재 상태가 무엇인가?"
      • Running, Ready (준비), Waiting (대기) 등.
    • PID / TID: "이 태스크의 식별자는 무엇인가?"
    • 스케줄링 우선순위 (Priority): "얼마나 중요한 작업인가?"

 

 

동작 매커니즘 (Interrupt/System Call 기준)

 컨텍스트 스위칭은 반드시 커널 모드(Kernel Mode)에서만 발생한다.

유저 모드(User Mode)에서 직접 다른 프로세스/스레드를 제어할 수 없다.

가장 일반적인 스위칭 과정을 단계별로 설명하자면... (Task A -> Task B)

  1. 트리거 발생 (Trigger): Task A가 실행 중일 때, 다음 중 하나의 이유로 스위칭이 필요.
    • 비자발적 (Involuntary):
      • 타이머 인터럽트: Task A에게 할당된 시간(Time Slice)이 만료됨.
      • I/O 인터럽트: Task A가 요청했던 디스크 읽기가 완료되어 다른 Waiting 상태의 Task C가 Ready 상태가 됨 (이때 스케줄러가 C를 실행하기로 결정할 수 있음).
    • 자발적 (Voluntary):
      • 시스템 콜 (System Call): Task A가 스스로 I/O 요청 (read, recv), sleep(), 혹은 Mutex lock() 등을 호출하여 Waiting 상태로 진입.
  2. 모드 스위치 (User Mode -> Kernel Mode):
    • 트리거(인터럽트 또는 시스템 콜)가 발생하면, CPU는 즉시 현재 실행 중인 Task A의 User Mode 실행을 중지.
    • 하드웨어는 Task A의 PC, SP, 일부 핵심 레지스터를 Task A의 커널 스택(Kernel Stack) 또는 특정 영역(TSS 등)에 자동으로 저장.
    • CPU는 커널 모드로 전환되고, 미리 정해진 커널 코드(인터럽트 핸들러 또는 시스템 콜 핸들러)로 점프.
  3. [Kernel] Task A 컨텍스트 저장 (Save Context):
    • 커널 핸들러가 실행.
    • 커널은 Task A의 나머지 모든 레지스터를 Task A의 커널 스택에 추가로 저장.
    • 이 모든 레지스터 값과 현재 상태(Ready 또는 Waiting)를 Task A의 TCB (Thread Control Block) 또는 PCB (Process Control Block)에 저장. (TCB/PCB는 운영체제가 스레드/프로세스를 관리하기 위한 자료구조)
  4. [Kernel] 스케줄러 실행 (Scheduler):
    • 커널의 스케줄러가 호출.
    • 스케줄러는 Ready Queue(실행 대기 중인 스레드/프로세스 목록)에서 다음에 실행할 Task B를 선택.
      • (Priority, Round-Robin 등 다양한 알고리즘 사용)
  5. [Kernel] Task B 컨텍스트 복원 (Load Context):
    • 커널은 Task B의 TCB/PCB에 저장되어 있던 모든 레지스터 값을 CPU 레지스터로 복원(Load).
  6. 모드 스위치 (Kernel Mode -> User Mode):
    • 커널은 iret (Interrupt Return) 같은 특수 명령어를 실행.
    • 이 명령어는 하드웨어에게 User Mode로 돌아가라고 지시.
    • 복원된 PC와 SP 값을 기반으로, CPU는 정확히 Task B가 이전에 멈췄던 그 지점부터 User Mode 실행을 재개.

 

 

프로세스 vs 스레드 스위칭: 결정적 차이

 이 부분이 서버 성능에 매우 중요하다.

  • 스레드 컨텍스트 스위칭 (동일 프로세스 내):
    • 저장/복원: 레지스터, 스택 포인터, PC.
    • 메모리 공간(Code, Data, Heap)은 공유하므로, 페이지 테이블을 교체할 필요가 없다.
    • 따라서 TLB(Translation Lookaside Buffer) 캐시가 유지된다.
  • 프로세스 컨텍스트 스위칭 (다른 프로세스 간):
    • 저장/복원: 레지스터, 스택 포인터, PC + 메모리 관리 정보 (페이지 테이블 포인터 CR3 등).
    • 메모리 공간이 완전히 다르므로, 가상 메모리 매핑(페이지 테이블)을 통째로 교체해야 한다.
    • 이 과정에서 주소 변환 캐시인 TLB가 모두 무효화(Flush)된다.

프로세스 스위칭은 스레드 스위칭보다 훨씬 더 비싸다.

TLB가 플러시되면, Task B가 실행될 때 모든 메모리 접근이 '캐시 미스'가 되어 페이지 테이블을 다시 탐색해야 하므로 막대한 성능 저하가 발생한다.

 

 


2. 문제점 및 핵심 해결 방안

 

 

문제점: "비싼" 작업

  1. 직접 비용 (Direct Cost): 레지스터를 TCB/PCB에 저장하고 복원하는 데 드는 순수 CPU 사이클.
  2. 간접 비용 (Indirect Cost):
    • 캐시 오염 (Cache Pollution):
      • Task A가 열심히 사용해서 CPU L1/L2/L3 캐시에 올려둔 데이터가, Task B가 실행되면서 모두 밀려난다.
      • Task B가 다시 실행될 때 (혹은 Task A가 다시 돌아왔을 때) 필요한 데이터가 캐시에 없어 메인 메모리(RAM)까지 접근해야 하므로(Cache Miss), CPU가 대기(Stall)하는 시간이 길어진다.
    • TLB 미스 (TLB Flush): (프로세스 스위칭 시) 위에서 설명한 TLB 플러시로 인한 성능 저하.

게임 서버처럼 초당 수천~수만 개의 요청을 처리해야 하는 환경에서 불필요한 컨텍스트 스위칭이 빈번하게 발생하면, CPU는 '실제 일(게임 로직)'보다 '작업 전환(스위칭)'에 시간을 더 쓰게 되어 전체 성능이 급격히 저하된다.

 

 

 

해결 방안: 스위칭을 피하는 기술

핵심은 커널이 개입하는 블로킹(Blocking)을 피하는 것

  1. 비동기(Asynchronous) / 논블로킹(Non-blocking) I/O:
    • 전통적인 블로킹 I/O (recv)는 데이터가 올 때까지 해당 스레드를 Waiting 상태로 만들고, 이는 즉시 컨텍스트 스위칭을 유발한다.
    • 논블로킹 I/O (epoll, kqueue, IOCP)는 "이벤트가 발생하면 알려줘"라고 커널에 등록만 해둔.
      • 스레드는 블로킹되지 않고 다른 작업을 계속할 수 있다.
    • 하나(혹은 소수)의 스레드가 수천 개의 I/O 이벤트를 동시에 처리할 수 있게 된다. (Proactor/Reactor 패턴)
  2. 스레드 풀 (Thread Pool) 활용:
    • 요청마다 스레드를 생성(Create)/파괴(Destroy)하는 것은 컨텍스트 스위칭보다 더 비싼 작업이다.
    • 미리 정해진 개수(보통 CPU 코어 수)의 스레드를 만들어두고, 작업(Task)을 큐(Queue)에 넣어 스레드들이 가져가 처리하게 한다.
    • 이는 불필요한 스레드 생성을 막고, 과도한 스레드 개수로 인한 잦은 스위칭(Thrashing)을 방지한다.
  3. 유저 레벨 스레드 (User-Level Threads / Fibers / Coroutines):
    • 커널의 개입 없이 유저 레벨 라이브러리가 스레드(처럼 보이는 것)의 컨텍스트 스위칭을 관리한다.
    • 스위칭 시 커널 모드 진입이 필요 없다. 단순히 PC와 스택 포인터만 유저 모드에서 바꾸고 점프한다.
      • C++20의 코루틴(Coroutine)이 대표적인 예.
        • co_await 키워드를 만나면, 해당 작업을 비동기로 요청, 실행 흐름을 즉시 반환(suspend)
        • 작업이 완료되면 나중에 그 지점부터 다시 시작(resume).이 과정이 커널 스위칭 X.

 

 


3. 실제 예시

게임 서버에서 가장 흔한 시나리오는 "다중 클라이언트 접속 처리"다.

 

 

나쁜 예시: 무분별한 스레드 생성 (Thread-per-Client)

 이 방식은 클라이언트 1만 명이 접속하면 스레드 1만 개가 생긴다.

대부분의 스레드는 recv()에서 블로킹되어 'Waiting' 상태일 것이며,

OS는 이 1만 개의 스레드를 관리하느라 컨텍스트 스위칭만 하다가 성능이 망가진다.

 
#include <iostream>
#include <thread>
#include <vector>
#include <boost/asio.hpp> // 예시를 위해 Asio 사용

using boost::asio::ip::tcp;

// 각 클라이언트를 처리하는 함수 (블로킹 방식)
void session(tcp::socket sock) {
    try {
        char data[1024];
        while (true) {
            // 1. 여기서 스레드는 블로킹됩니다.
            // 2. 데이터가 올 때까지 스레드는 'Waiting' 상태가 됩니다.
            // 3. 커널은 이 스레드를 잠재우고 다른 스레드를 깨우는 '컨텍스트 스위칭'을 수행합니다.
            size_t length = sock.read_some(boost::asio::buffer(data)); 
            
            // ... (데이터 처리 로직) ...
            boost::asio::write(sock, boost::asio::buffer(data, length));
        }
    } catch (std::exception& e) {
        std::cerr << "Exception in thread: " << e.what() << "\n";
    }
}

int main() {
    boost::asio::io_context io_ctx;
    tcp::acceptor acceptor(io_ctx, tcp::endpoint(tcp::v4(), 12345));

    while (true) {
        tcp::socket socket = acceptor.accept(); // 여기서도 블로킹

        // [문제점] 
        // 접속마다 스레드를 생성합니다.
        // 클라이언트 1000명 = 스레드 1000개 = 엄청난 컨텍스트 스위칭 오버헤드
        std::thread(session, std::move(socket)).detach();
    }
    return 0;
}

 

 

좋은 예시 1: 비동기 I/O + 스레드 풀 (Proactor 패턴, Boost.Asio)

현대적인 게임 서버의 표준 방식. 스레드 풀(예: CPU 코어 수만큼 8개)을 사용하고, 모든 I/O는 비동기로 처리한다.

#include <iostream>
#include <thread>
#include <memory>
#include <boost/asio.hpp>

using boost::asio::ip::tcp;

class session : public std::enable_shared_from_this<session> {
public:
    session(tcp::socket socket) : socket_(std::move(socket)) {}

    void start() {
        do_read(); // 첫 비동기 읽기 시작
    }

private:
    void do_read() {
        auto self(shared_from_this());
        // [핵심 1] 비동기 읽기를 '요청'하고 즉시 리턴합니다.
        // 스레드는 블로킹되지 않고 다른 일을 하러 갑니다. (e.g., 다른 세션의 완료된 작업)
        socket_.async_read_some(boost::asio::buffer(data_, 1024),
            [this, self](boost::system::error_code ec, std::size_t length) {
                if (!ec) {
                    do_write(length); // 읽기가 '완료'되면 이 핸들러가 호출됩니다.
                }
            });
    }

    void do_write(std::size_t length) {
        auto self(shared_from_this());
        // [핵심 2] 비동기 쓰기를 '요청'합니다.
        boost::asio::async_write(socket_, boost::asio::buffer(data_, length),
            [this, self](boost::system::error_code ec, std::size_t /*length*/) {
                if (!ec) {
                    do_read(); // 쓰기가 '완료'되면 다시 비동기 읽기를 시작합니다.
                }
            });
    }
    tcp::socket socket_;
    char data_[1024];
};

class server {
public:
    server(boost::asio::io_context& io_ctx, short port)
        : acceptor_(io_ctx, tcp::endpoint(tcp::v4(), port)), io_context_(io_ctx) {
        do_accept();
    }
private:
    void do_accept() {
        // [핵심 3] 접속 요청도 비동기로 받습니다.
        acceptor_.async_accept(
            [this](boost::system::error_code ec, tcp::socket socket) {
                if (!ec) {
                    // 접속이 완료되면 세션 객체를 만들고 시작
                    std::make_shared<session>(std::move(socket))->start();
                }
                do_accept(); // 다음 접속을 기다립니다.
            });
    }
    tcp::acceptor acceptor_;
    boost::asio::io_context& io_context_;
};

int main() {
    try {
        boost::asio::io_context io_ctx;
        server s(io_ctx, 12345);

        // [핵심 4] CPU 코어 수만큼 스레드 풀 생성
        int num_threads = std::thread::hardware_concurrency();
        std::vector<std::thread> threads;
        for (int i = 0; i < num_threads; ++i) {
            threads.emplace_back([&io_ctx]() { 
                // 이 스레드들은 io_ctx.run() 내부에서 대기하다가
                // '완료된 비동기 작업(핸들러)'이 있을 때만 깨어나서 실행합니다.
                // 따라서 불필요한 컨텍스트 스위칭이 발생하지 않습니다.
                io_ctx.run(); 
            });
        }
        for (auto& t : threads) t.join();

    } catch (std::exception& e) {
        std::cerr << "Exception: " << e.what() << "\n";
    }
    return 0;
}

 

 

좋은 예시 2: C++20 코루틴 (User-Level Context Switching)

가장 최신 방식이며, 비동기 코드를 동기 코드처럼 쉽게 작성하게 해준다.

co_await가 커널 스위칭 없이 유저 레벨에서 실행 흐름을 '일시 중단' 시킨다.

// (Boost.Asio와 C++20 Coroutine을 함께 사용)
#include <boost/asio.hpp>
#include <boost/asio/co_spawn.hpp> // co_spawn
#include <boost/asio/detached.hpp> // detached
#include <boost/asio/use_awaitable.hpp> // use_awaitable
#include <iostream>

using boost::asio::ip::tcp;
namespace this_coro = boost::asio::this_coro;

// [핵심 1] 코루틴 함수는 `awaitable`을 반환
boost::asio::awaitable<void> session(tcp::socket socket) {
    try {
        char data[1024];
        for (;;) {
            // [핵심 2] 'co_await'
            // 1. 비동기 읽기를 요청합니다.
            // 2. 이 'session' 코루틴(함수)은 여기서 즉시 '일시 중단(suspend)'됩니다.
            // 3. 실행 제어권은 io_context.run()으로 즉시 반환됩니다. (커널 스위칭 아님!)
            // 4. 스레드는 다른 작업을 처리합니다.
            // 5. 나중에 읽기가 완료되면, 스레드 풀의 스레드가 이 지점부터 실행을 '재개(resume)'합니다.
            std::size_t length = co_await socket.async_read_some(
                boost::asio::buffer(data), boost::asio::use_awaitable
            );

            // ... (데이터 처리 로직) ...

            // [핵심 3] 쓰기도 마찬가지로 'co_await'
            co_await boost::asio::async_write(
                socket, boost::asio::buffer(data, length), boost::asio::use_awaitable
            );
        }
    } catch (std::exception& e) {
        std::printf("Session exception: %s\n", e.what());
    }
}

boost::asio::awaitable<void> listener() {
    auto executor = co_await this_coro::executor;
    tcp::acceptor acceptor(executor, {tcp::v4(), 12345});
    for (;;) {
        // [핵심 4] 접속 대기도 'co_await'
        tcp::socket socket = co_await acceptor.async_accept(boost::asio::use_awaitable);
        
        // 새 세션 코루틴을 시작시킴 (io_context가 관리)
        boost::asio::co_spawn(executor, session(std::move(socket)), boost::asio::detached);
    }
}

int main() {
    try {
        boost::asio::io_context io_ctx(1); // 스레드 1개로도 수천 동접 처리가 가능!
        
        // 리스너 코루틴 시작
        boost::asio::co_spawn(io_ctx, listener(), boost::asio::detached);

        // 스레드 풀 (여기선 1개)이 비동기 이벤트 루프를 실행
        io_ctx.run();

    } catch (std::exception& e) {
        std::printf("Exception: %s\n", e.what());
    }
    return 0;
}

 

 


4.  기타 중요 고려 사항

 

 

유저 모드 vs 커널 모드

  • 유저 모드 (User Mode): 어플리케이션 코드(게임 로직)가 실행되는 영역. 하드웨어/메모리에 직접 접근 불가능.
  • 커널 모드 (Kernel Mode): OS 커널 코드가 실행되는 영역. 모든 하드웨어/메모리 접근 가능.
  • 시스템 콜(System Call)은 유저 모드에서 커널 모드로의 '모드 스위치'를 유발합니다. (recv, send, open, close 등)
  • 중요: 모드 스위치 자체도 비용이 들지만, 모드 스위치(Mode Switch)가 항상 컨텍스트 스위치(Context Switch)를 의미하진 않다.
    • getpid() 같은 간단한 시스템 콜은 커널 모드로 전환되어 ID만 반환하고, 다시 원래 Task로 돌아온다. (컨텍스트 스위치 X)
    • read()가 블로킹되면, 커널 모드로 전환된 후 컨텍스트 스위치가 발생한다.
  •  

Mutex vs Spinlock: 스위칭 관점에서의 고찰

이것도 컨텍스트 스위칭과 직결되는 단골 손님이다.

  • 뮤텍스 (Mutex):
    • 임계 영역(Critical Section) 진입 시 lock()을 시도.
    • 이미 다른 스레드가 락을 점유 중이면, 해당 스레드는 'Waiting' 상태로 전환.
    • OS는 이 스레드를 잠재우고 컨텍스트 스위칭을 발생시켜 다른 스레드를 실행.
    • 장점: 락을 기다리는 동안 CPU를 낭비하지 않고 다른 스레드가 일할 수 있음.
    • 단점: 락이 아주 잠깐만 잡혀있다가 풀려날 경우에도, 불필요하게 비싼 컨텍스트 스위칭(잠들기->깨어나기) 비용이 발생함.
    • 용도: 락을 점유하는 시간이 길 것으로 예상될 때 사용한다.
  • 스핀락 (Spinlock):
    • 임계 영역 진입 시 lock()을 시도.
    • 이미 락이 점유 중이면, 스레드는 **'무한 루프(Busy-Waiting)'**를 돌면서 락이 풀리기를 대기.
    • 컨텍스트 스위칭이 발생하지 않습니다. 스레드는 'Running' 상태를 유지하며 CPU를 100% 사용.
    • 장점: 락이 즉시(수십~수백 사이클 내) 풀릴 경우, 비싼 컨텍스트 스위칭 비용을 아낄 수 있어 훨씬 빠름.
    • 단점: 락이 오래 점유되면, 기다리는 스레드가 CPU 자원을 낭비하면서 다른 스레드의 실행을 방해. (특히 싱글 코어에서는 최악)
    • 용도: 락을 점유하는 시간이 매우 짧다고 확신할 때, 주로 멀티 코어 환경의 커널 내부나 고성능 드라이버에서 사용.

 

반응형