본문 바로가기

Computer Science

Thread

Multi Process

다중 프로세스는 리눅스의 fork 방식을 사용합니다. 부모 프로세스가 사용자 요청을 수신하고, 자식 프로세스를 만들어서 이를 처리합니다.

멀티 프로세스를 코드에 어떻게 적용할까요? 아래 코드와 같이 독립적인 함수들을 독립된 프로세스로 실행하고 마지막에 합치는 방식으로 적용할 수 있습니다. 

int main()
{
    int resA = funcA();
    int resB = funcB();
    
    print(resA + resB);
    
    return 0;
}

이건 다중 프로세스 프로그래밍과 프로세스 간 통신(IPC)으로 구현이 가능합니다. 다만, 각 프로세스의 주소 공간이 서로 격리되어 있기 때문에 프로세스 간 통신이 어렵습니다. 이로 인해 프로세스 자체 주소공간이 있어 복잡해지고, 자식 프로세스를 생성할 때 부담이 있으며 빈번한 생성과 종료는 OS에 오버헤드를 발생시키는 문제가 있습니다.

 

만약 하나의 프로세스에 진입 함수가 두 개 이상 있다면, 이 프로세스의 코드를 여러 CPU가 동시에 실행할 수 있지 않을까요?

 

 

Thread per Connection

스레드는 실행 흐름입니다. 각 CPU의 PC 레지스터에 스레드 진입 함수 주소를 지정하면 여러 스레드를 실행할 수 있습니다!

각 요청에 대응하는 스레드(thread-per-connection)을 생성하면 일부 스레드가 블로킹되어 일시중지되더라도 다른 스레드에 영향을 미치지 않습니다. 스레드는 프로세스 주소 공간을 공유하기 때문에 스레드 간 통신에 편리함을 제공합니다.

멀티 스레드는 코드에 어떻게 적용할까요? C언어에서 스레드를 조작하는 표준 인터페이스인 posix 스레드를 이용해 보겠습니다.  아래 코드와 같이 메인 스레드는 피어 스레드를 생성하고, 종료될 때까지 기다립니다. 피어 스레드는 "Hello, world!\n"를 출력하고 종료합니다. 메인 스레드는 피어 스레드의 종료를 확인하면, exit를 호출해서 프로세스를 종료합니다.

# include "csapp.h"
void *thread(void *vargp);

int main()
{
    pthread_t tid;
    Pthread_create(&tid, NULL, thread, NULL);	// 새로운 피어 스레드 생성
    Pthread_join(tid, NULL);	// join을 호출한 main 스레드는 tid 스레드가 종료될 때까지 block 됩니다.
    exit(0);
}

void *thread(void *vargp)
{
    printf("Hello, world!\n");
    return NULL;
}

 

Pthread_create(&tid, NULL, thread, NULL);는 리턴할 때 인자 tid를 새롭게 만들어진 스레드 ID로 변경합니다.

이렇게 만들어진 스레드는 다음 방법들 중 하나로 종료됩니다.

  1. 스레드 생성 시 넘긴 함수가 리턴될 때, 묵시적으로 종료
    (위 코드의 경우, thread 함수가 리턴되면 종료)
  2. pthread_exit 함수를 호출해서 명시적으로 종료
    (pthread_exit를 호출한 스레드는 즉시 종료)
  3. 프로세스 자체를 종료
    (exit, abort와 같은 프로세스 종료 함수를 호출하면 프로세스 내 모든 스레드와 프로세스가 강제 종료)
  4. 특정 스레드를 협조적으로 종료시키는 pthread_cancel 함수를 호출
    (해당 스레드가 취소 가능 상태일 때만 종료되며 취소 가능 상태는 block 상태)

이렇게 종료된 스레드는 terminated 상태로 남게 되고 Pthread_join(tid, NULL) 함수가 호출되면, OS는 TCB와 스레드 스택 메모리 등을 회수하게 됩니다.

💡 스레드의 상태 joinable vs detached
스레드는 joinable 또는 detached 상태를 가집니다.
joinbale 상태는 스레드가 종료되어도 다른 스레드가 pthread_join()을 호출하여 자원을 회수해야 합니다. 만약 회수하지 않으면 메모리 누수가 발생할 수 있습니다.
detached 상태는 스레드가 종료되는 시점에 시스템이 자동으로 자원을 회수합니다. 만약 다른 스레드에서 pthread_join()을 호출하여 detached 스레드의 자원 회수를 시도하면 실패하며 에러가 발생합니다.
기본적으로 스레드는 joinable 상태로 생성되며 pthread_detach(pthread_t tid) 함수로 detached 상태로 전환할 수 있습니다.
pthread_once
여러 스레드에서 전역/정적 변수를 초기화 되는 걸 방지하기 위해, pthread_once 함수를 호출할 수 있습니다. 이는 데이터 무결성과 경쟁 조건(race condition)을 방지하는 역할을 합니다.

 

 

하지만 프로세스를 공유하는 스레드 중 하나라도 문제가 생겨서 강제 종료되면 나머지 스레드를 포함해서 프로세스가 강제 종료됩니다. 이들 스레드가 동시에 공유 리소스를 읽고 쓰면 스레드 안전 문제가 발생해서 동기화 시 반드시 상호배제와 같은 방식을 써야 하기도 합니다.

이러한 단점들이 있지만 멀티 프로세스보다 스레드가 더 유리합니다. 하지만 만약 동시 요청 수가 매우 많으면 다중 스레드만으로 한계가 있다.

스레드와 CPU의 연관 관계
스레드는 OS 계층에 구현되며 코어 개수와는 직접적인 상관이 없습니다. 그래서 CPU가 일을 하려고 기계명령어를 읽을 때, 이 코드가 어떤 스레드에 속해있는지 알지 못합니다. 그저 할 일을 할 뿐이죠.

 

 

Thread Pool

하나의 프로세스 내에 실행 흐름이 여러 개이려면, 각 흐름의 실행 정보를 저장하기 위한 스택 영역이 여러 개가 필요합니다. 그래서 스레드마다 스택영역이 있고, 그만큼 메모리 공간을 차지하겠죠. 이때 발생할 수 있는 문제는 어떤 게 있을까요?

요청당 스레드(thread-per-request) 방식으로 파일 저장과 같은 long task를 별도의 스레드에 할당해보겠습니다. 이때는 긴 작업들에 대해 별도 스레드가 처리를 하기에, 별 문제가 없어 보입니다.

 

하지만 서버 네트워크 요청, db 요청 작업과 같은 short task를 상대하면 다음과 같은 문제가 발생합니다.

  1. 스레드의 생성과 종료에 오버헤드가 발생
  2. 스레드마다 스택영역이 생성되면 리소스 낭비
  3. 스레드 수가 많으면 스레드 간 전환 시 부담 증가

 

이를 해결하는 방법으로 스레드 풀(thread pool)을 사용할 수 있습니다.

여러 개 스레드를 미리 생성합니다. 작업이 생길 때마다 생성과 삭제를 반복하지 않고 재사용합니다.

스레드 풀은 생산자-소비자 패턴으로 작업을 스레드에 전달합니다.

생산자 스레드는 작업 전달 역할을 합니다.

소비자 스레드는 작업 처리 역할을 합니다.

생산자 스레드에서 작업 대기열로 작업을 보냅니다. 작업은 작업 대기열에서 블로킹 상태로 대기를 합니다. 그리고 대기열에 들어온 순서대로 소비자 스레드로 전달됩니다.

 

 

스레드 전용 리소스와 공유 리소스

상태 변화 관점에서 스레드를 보면 함수를 실행하는 것이라 할 수 있습니다. 그리고 함수의 실행 시간 정보(thread context)는 스택 프레임에 저장됩니다. thread context는 PC 레지스터, 스택 포인터 등의 레지스터들이 포함되고 스레드 전용 리소스입니다. 그 외에는 모두 스레드 간에 공유되는 리소스입니다. 다음 그림을 보며 어떤 리소스가 스레드 전용 리소스인지, 공유 리소스인지 확인해 보겠습니다.

 

- 스택 영역: 스레드의 스택 영역은 스레드 전용 공간으로 추상화되어 있지만, 프로세스 간 접근을 막는 것처럼 스레드 간 스택 영역을 접근하는 것을 막는 것은 아닙니다. 하지만 가능하다고 해서 권장하진 않습니다. 원인을 찾을 수 없는 디버깅이 어려운 버그로 이어질 수 있기 때문입니다.

 

- 스레드 전용 저장소

[TLS 영역]       ← 각 스레드별 별도 공간(user space)에 존재

이 영역에 저장된 변수는 모든 스레드에서 접근 가능하지만, 변수의 인스턴스는 각각의 스레드에 속하므로 변경사항이 공유되지 않습니다.

__thread int a = 1;  // 전역 변수 & 스레드 전용 저장소

void print_a()
{
     cout << a << end1;
}

void run()
{
     ++a;
     print_a();
}

void main()
{
    thread t1(run);
    t1.join();
    thread t2(run);
    t2.join();
}

출력 결과는 다음과 같습니다.

2
2

 

 

정적 링크: 종속된 모든 라이브러리가 실행 파일에 모든 코드와 데이터가 포함
동적 링크: 종속된 모든 라이브러리가 실행 파일에 포함되어 있지 않아서 프로그램 시작 또는 실행 중에 찾아서 프로세스 주소 공간에 넣는 링크 과정이 필요. 이 데이터와 코드는 스택과 힙 영역 사이 공유 라이브러리 공간에 배치됨. 모든 스레드가 공유함.


- 데이터 영역: 전역 변수가 저장되는 곳으로, 프로그램이 실행되는 동안 데이터 영역 내에 전역 변수의 인스턴스는 하나만 있습니다.

 

- 코드 영역: 컴파일러가 소스 파일을 실행파일로 만들고, 실행파일의 코드영역이 프로세스의 코드영역에 적재됩니다. 모든 스레드가 공유하지만 읽기 전용이므로 스레드 안전 문제가 발생하지 않습니다.


- 힙 영역: malloc 함수로 요청하는 메모리이며, 포인터가 있으면 모든 스레드가 포인터가 가리키는 데이터에 접근 가능합니다.

 


 

💡잠깐! 스레드 안전(thread safety)
스레드가 자신만의 전용 데이터를 사용한다면 스레드 안전이라고 할 수 있습니다.

어떤 코드를 스레드 몇 개에서 실행하든, 어떤 순서로 실행하든 상관없이 올바른 결과가 나온다면 스레드 안전입니다.

 

스레드 전용 리소스는 함수의 지역 변수, 스레드 스택 영역, 스레드 전용 저장소입니다.
공유 리소스그 외의 모든 영역입니다.
공유 리소스를 쓸 때는 반드시 순서를 따라야 하고 다른 스레드를 방해할 수 없도록 lock 또는 세마포어 같은 장치를 이용해야 합니다.

공유 리소스라도 read only는 스레드 안전인 공유 리소스, read write는 스레드 안전이 아닌 공유 리소스입니다.
주소를 매개변수로 넘기거나 힙메모리를 사용할 경우 모든 스레드가 접근할 수 있기 때문에 문제가 될 수 있습니다. 상수처럼 사용되면 괜찮습니다.

함수의 반환 값도 만약 포인터를 반환하면 스레드 안전을 주의해야 합니다.

 


 

 

 

Multi-Thread

 

이벤트 기반 프로그래밍(event based programming), 이벤트 기반 동시성(event based concurrency)

여기서 말하는 이벤트는 서버의 입출력에 관계된 것입니다. ex) 네트워크 데이터의 수신 여부, 파일의 읽기 쓰기 가능 여부

이벤트를 처리하는 함수를 이벤트 핸들러(event handler)라고 합니다.

서버에서 이벤트는 사용자 요청이며, 기본적으로 이벤트는 계속 발생합니다. 이 이벤트를 계속 수신하고 처리해야 하고, while이나 for 반복문을 이용해서 반복적으로 처리합니다. 이 반복을 이벤트 순환(event loop)라고 합니다.

while(true)
{
    event = getEvent();  // 이벤트 수신 대기
    handler(event);      // 이벤트 처리
}

하지만 여기도 두 가지 문제가 있습니다.

  1. 코드의 getEvent 함수 하나로 여러 이벤트를 가지고 올 수 있을까요?
  2. 이벤트 핸들러가 반드시 이벤트 루프에서 실행되어야 할까요?

 

1. 코드의 getEvent 함수 하나로 여러 이벤트를 가지고 올 수 있을까요?

=> 1번은 입출력 다중화(input/output multiplexing)

리눅스, 유닉스 세계에서는 모든 것을 파일로 취급합니다. file-descriptor를 사용하여 입출력 작업을 실행하며, 이는 소켓도 마찬가지입니다. 입출력 다중화란 누군가에게 소켓 서술자들을 감시하고 있다가, 데이터가 들어오면 알려달라고 하는 것입니다. ex) epoll

(코드나 이미지 필요)

 

2. 이벤트 핸들러가 반드시 이벤트 루프에서 실행되어야 할까요?

=> 2번 반응자 패턴(reactor pattern)

만약 처리할 이벤트의 소요시간이 짧고 입출력 작업이 전혀 없다면 이벤트 루프에서 실행되어도 됩니다. 하지만 그렇지 않다면 이벤트 루프에서 처리하는 단일 스레드로 인해 처리 속도를 높일 방법이 없습니다. 작업자 스레드 여러 개와 이벤트 루프 스레드 한 개를 생성하면 요청 수신 후 바로 각 스레드에 분배가 가능합니다.

 

 

 

출처

운영체제 (3강 프로세스 관리, 4강 CPU Scheduling) by 반효경 

컴퓨터 밑바닥의 비밀 (2장 프로그램이 시작되었지만, 뭐가 뭔지 하나도 모르겠다) by 루 샤오펑

 

'Computer Science' 카테고리의 다른 글

힙 메모리 할당 (Heap Memory Allocation) 구현  (0) 2025.10.25