운영체제

프로세스와 쓰레드

뭉크테크 2025. 1. 12. 23:06

프로세스와 쓰레드 개념

프로세스라 함은 OS 관리 단위이고, 프로그램이 RAM 위에 올라가면 이를 프로세스라 부른다. 그리고 프로세스 내부에 연산할 거리가 있는데, 이를 Thread 라 부른다. 이러한 Thread는 프로세스 하나당 최소 하나 이상으로 이루어져, 쓰레드라는 흐름을 형성한다.

프로세스를 코드 덩어리로 본다면, 스레드는 그 코드안에 하나의 함수 단위로 생각해보거나, 그 함수안에 코드 한 줄이나 두 줄로 생각해보면 쉬울것이다. 

이때, 쓰레드 흐름이 여러 개가 될 수 있고, 이를 멀티 threding이라 부른다. 

 

자원

캄퓨터에서 자원은 CPU를 사용하는 경우와 RAM을 사용하는 경우가 있다. 또한 HDD같은 2차 메모리에서도 사용을 한다. 여기서 RAM + HDD를 Virtual Memory형태로 관리를 한다.

 

즉, 자원이라고 하면

CPU 코어

(user mode application process) Virtual Memory이다.

 

그래서 자원이라고 하는 것을 운영체제마다 다르겠지만 기본적으로 프로세스한테 준다. 윈도우 OS기준으로는 스레드 기준으로 CPU 코어 하나를 준다. 그리고 Virtual Memory같은 것은 process단위로 주어진다. 그리고 그 안에 thred들은 소속된 프로세스의 Virtual Memory 공간안에서만 사용이 가능하다. 이 Virtual Memory 공간은 말 그대로 가상의 메모리이기에 실제로 물리적 공간은 가상 메모리상안에서는 서로 근접해 있을 지라도 그 둘은 서로 다른 영역에 있을 수 있다. 예를 들어, 어느 한 데이터는 실제 RAM 에 저장될 수도 있지만, 어떤 곳은  HDD의 SWAP 영역에 있을 수 있다는 거다.

 

그리고 프로세스에게 가상 메모리 공간을 할당해줄 때, Code, data, heap, stack 이라는 영역으로 나눠 전달해준다. 

https://www.tcpschool.com/c/c_memory_structure

 

  • 이때, 쓰레드는 스레드는 프로세스 내에서 각각 Stack과 레지스터만 따로 할당받고 Code, Data, Heap 영역은 공유한다.
    스레드는 한 프로세스 내에서 동작되는 여러 실행의 흐름으로, 프로세스 내의 주소 공간이나 자원들(힙 공간 등)을 같은 프로세스 내에 스레드끼리 공유하면서 실행된다.
  • 스택에는 지역변수 + 자동변수에 쓰는데 용량이 굉장히 작다. 
  • 그리고 레지스터 정보에는 TCB(Thread Control Block)가 있는데 스레드마다 실행이 이루어지고 연산을 하고 CPU의 코어가 하다보니 이 코어마다 레지스터들이 있을 것이고, 그 레지스터 정보에 TCB가 저장되고, 스위칭되는데, 이를 Thread 수준에서의 Context Switching 이라 부른다.
  • 반면에 프로세스는 다른 프로세스의 메모리에 직접 접근할 수 없다. 각각의 스레드는 별도의 레지스터와 스택을 갖고 있지만, 힙 영역은 서로 읽고 쓸 수 있다. 그러나 코드 영역은 읽기 전용이라 쓰는 것은 거의 하지않으며, 데이터 영역은 예측의 어려움과 캡슐화 문제로 인해 이것도 잘 안 안쓰기는 한다. 그러나 일단, 공유는 한다.
  • 한 스레드가 프로세스 자원을 변경하면, 다른 이웃 스레드(sibling thread)도 그 변경 결과를 즉시 볼 수 있다. 그러나 만약 특정 코드를 실행하는 함수에서 오류가 난다면, 다른 함수에도 영향이 가고 결국에는 프로세스 전체에 오류가 걸려 결국, 다른 thread에게 영향이 간다.
  • 그럼에도 이 thread 단위로 생각해봐야하는 이유는 그만큼 효율적이기 때문이다. 이는 아래 멀티 프로세싱과 멀티 쓰레딩의 차이를 보면 이해하기 쉽다.

 

멀티 프로세스와 멀티 스레드의 차이

멀티 프로세스

  • 정의: 하나의 응용프로그램을 여러 개의 프로세스로 구성하여 각 프로세스가 하나의 작업(태스크)을 처리하도록 하는 것이다.
  • 장점
    • 여러 개의 자식 프로세스 중 하나에 문제가 발생하면 그 자식 프로세스만 죽는 것 이상으로 다른 영향이 확산되지 않는다.
  • 단점
    1. Context Switching에서의 오버헤드
      • Context Switching 과정에서 캐쉬 메모리 초기화 등 무거운 작업이 진행되고 많은 시간이 소모되는 등의 오버헤드가 발생하게 된다.
      • 프로세스는 각각의 독립된 메모리 영역을 할당받았기 때문에 프로세스 사이에서 공유하는 메모리가 없어, Context Switching가 발생하면 캐쉬에 있는 모든 데이터를 모두 리셋하고 다시 캐쉬 정보를 불러와야 한다.
    2. 프로세스 사이의 어렵고 복잡한 통신 기법(IPC)
      • 프로세스는 각각의 독립된 메모리 영역을 할당받았기 때문에 하나의 프로그램에 속하는 프로세스들 사이의 변수를 공유할 수 없다.
    3. 참고 Context Switching란?
      • CPU에서 여러 프로세스를 돌아가면서 작업을 처리하는 데 이 과정을 Context Switching라 한다.
        구체적으로, 동작 중인 프로세스가 대기를 하면서 해당 프로세스의 상태(Context)를 보관하고, 대기하고 있던 다음 순서의 프로세스가 동작하면서 이전에 보관했던 프로세스의 상태를 복구하는 작업을 말한다.

멀티 스레드

  • 정의
    • 하나의 응용프로그램을 여러 개의 스레드로 구성하고 각 스레드로 하여금 하나의 작업을 처리하도록 하는 것이다.
    • 윈도우, 리눅스 등 많은 운영체제들이 멀티 프로세싱을 지원하고 있지만 멀티 스레딩을 기본으로 하고 있다.
    • 웹 서버는 대표적인 멀티 스레드 응용 프로그램이다.
  • 장점
    1. 스레드 사이의 작업량이 작아 Context Switching이 빠르다.
    2. 스레드는 프로세스 내의 Stack 영역을 제외한 모든 메모리를 공유하기 때문에 통신의 부담이 적다.
  • 단점
    1. 멀티 스레드의 경우 자원 공유의 문제가 발생한다. (동기화 문제)
    2. 하나의 스레드에 문제가 발생하면 전체 프로세스가 영향을 받는다.

 

정말 다른 프로세스의 정보에는 접근할 수 없을까?

프로세스 간 정보를 공유하는 방법에는 다음과 같은 방법들이 있다. 다만 이 경우에는 단순히 CPU 레지스터 교체뿐만이 아니라 RAM과 CPU 사이의 캐시 메모리까지 초기화되기 때문에 앞서 말했듯 자원 부담이 크다.

  1. IPC(Inter-Process Communication)을 사용한다.
  2. LPC(Local inter-Process Communication)을 사용한다.
  3. 별도로 공유 메모리를 만들어서 정보를 주고받도록 설정해주면 된다.

 

멀티쓰레딩의 문제(동기화)

이렇게 좋은 점만 있을 줄 알았지만, 동기화라는 문제가 발생한다. 즉, 자원 공유에 있어 RaceCondition 같은 문제가 발생하게되는 것이다.  아래 예시 코드로 자세히 알아보자

 

#include <iostream>
#include <Windows.h>
#include <process.h>

// 동기화 없이 공유 변수에 접근하는 예제(레이스 컨디션 발생 가능)

// 전역 변수
int g_data;         // 두 스레드가 교대로 값을 써 넣을 변수
bool g_bFlag = true; // while 루프를 제어할 플래그

// 스레드 함수 1: g_data에 1000을 계속 대입
void threadFunction01(void * pArgs)
{
    std::cout << "threadFunction01! - Begin\n";

    while(g_bFlag) {
        g_data = 1000;   // 교대로 덮어쓴다(동기화 없음)
    }

    std::cout << "threadFunction01! - End\n";
}

// 스레드 함수 2: g_data에 2000을 계속 대입
void threadFunction02(void * pArgs)
{
    std::cout << "threadFunction02! - Begin\n";

    while(g_bFlag) {
        g_data = 2000;   // 교대로 덮어쓴다(동기화 없음)
    }

    std::cout << "threadFunction02! - End\n";
}

// 스레드 함수 3: g_data를 10번 출력
void threadFunction03(void * pArgs)
{
    for (int i = 0; i < 10; i++) {
        std::cout << "threadFunction03() :: g_data = " << g_data << std::endl;
        ::Sleep(0);  // CPU를 양보해 다른 스레드가 실행될 기회를 주도록 대기열 뒤에 슴
    }
}

int main()
{
    std::cout << "Hello World! - Begin\n";

    // 스레드가 while 루프 안을 돌도록 true로 설정
    g_bFlag = true;

    // 세 개의 스레드 생성
    ::_beginthread(threadFunction01, 0, nullptr);
    ::_beginthread(threadFunction02, 0, nullptr);
    ::_beginthread(threadFunction03, 0, nullptr);

    // 메인 스레드(호출자 스레드)를 100ms 잠시 대기
    // 그 사이 다른 스레드들이 g_data를 읽고 쓸 수 있는 시간 확보
    ::Sleep(100); // 우연에 맡기는 코드

    // 플래그를 false로 바꾸어
    // threadFunction01, threadFunction02의 while 루프를 끝내도록 함
    g_bFlag = false;

    std::cout << "Hello World! - End\n";
    return 0;
}

 

위의 코드를 보면 c++로 스레드간 race-condition이 발생한 상태이다. 매인 함수안에 각각의 스레드 함수를 실행시켰다. 그리고 호출자 스레드를 대기상태로 전환을 하면 어떻게 될까? 어느 스레드가 먼저 호출될까? 그건 모른다.

심지어 메인 스레드가 종료가 되고나서 스레드1번이 종료될 수 있고 나머지 스레드들은 종료가 안 될 수 있다. 각 코어당 하나씩 쓰레드를 사용하니 해당 코어의 대기큐 상태나 성능에 따라 오히려 1번 쓰레드보다 2번 쓰레드가 먼저 실행될 수 있는 상황이다.

 

위 코드 실행 결과

 

실제 코드 결과를 보면, 쓰레드 3번은 끝나지도 않았는데, 프로세스 끝나버렸고, g_data 라는 공유 자원에 대해 지금 1번 2번 스레드 서로 write 하는 바람에 현재 3번 쓰레드에서 출력된 결과를 보면 1000과 2000을 번갈아가며 출력되고 있다. 하나의 공유 자원을 두고 두 쓰레드가 경쟁하고 있는 상황이다.

 

그럼 이 부분을 어떻게 동기화(교통정리)를 해줄까?

#include <iostream>
#include <Windows.h>
#include <process.h>

// -------------------------
// 전역 변수 & 객체
// -------------------------
int  g_data = 0;         // 공유 데이터
bool g_bFlag = true;     // 쓰레드 루프 제어 플래그

HANDLE g_hThreadExit01, g_hThreadExit02, g_hThreadExit03; 
CRITICAL_SECTION g_cs;   // 크리티컬 섹션 객체 (동기화용)

// -------------------------
// 스레드 함수 1
// -------------------------
void threadFunction01(void* pArgs)
{
    std::cout << "threadFunction01! - Begin\n";

    while(g_bFlag)
    {
        // ---- 크리티컬 섹션 진입 ----
        EnterCriticalSection(&g_cs);
        g_data = 1000;
        LeaveCriticalSection(&g_cs);
        // ---- 크리티컬 섹션 종료 ----
        
        // 너무 빨리 반복하면 콘솔이 난잡해질 수 있으므로 약간 쉬어줌
        ::Sleep(0);
    }

    std::cout << "threadFunction01! - End\n";
    ::SetEvent(g_hThreadExit01); // 스레드 종료 알림
}

// -------------------------
// 스레드 함수 2
// -------------------------
void threadFunction02(void* pArgs)
{
    std::cout << "threadFunction02! - Begin\n";

    while(g_bFlag)
    {
        // ---- 크리티컬 섹션 진입 ----
        EnterCriticalSection(&g_cs);
        g_data = 2000;
        LeaveCriticalSection(&g_cs);
        // ---- 크리티컬 섹션 종료 ----
        
        ::Sleep(0);
    }

    std::cout << "threadFunction02! - End\n";
    ::SetEvent(g_hThreadExit02); // 스레드 종료 알림
}

// -------------------------
// 스레드 함수 3
// -------------------------
void threadFunction03(void* pArgs)
{
    // g_data를 여러 번 읽어서 출력
    for (int i = 0; i < 10; i++)
    {
        // ---- 크리티컬 섹션 진입 ----
        EnterCriticalSection(&g_cs);
        std::cout << "threadFunction03() :: g_data = " << g_data << std::endl;
        LeaveCriticalSection(&g_cs);
        // ---- 크리티컬 섹션 종료 ----
        
        ::Sleep(0);
    }

    ::SetEvent(g_hThreadExit03); // 스레드 종료 알림
}

// -------------------------
// main 함수
// -------------------------
int main()
{
    std::cout << "Hello World! - Begin\n";

    // 1) 크리티컬 섹션 초기화
    InitializeCriticalSection(&g_cs);

    // 2) 이벤트 생성 (스레드 종료 대기용)
    g_hThreadExit01 = ::CreateEventA(nullptr, true, false, "T_THREAD_01");
    g_hThreadExit02 = ::CreateEventA(nullptr, true, false, "T_THREAD_02");
    g_hThreadExit03 = ::CreateEventA(nullptr, true, false, "T_THREAD_03");

    // 3) 스레드 시작
    ::_beginthread(threadFunction01, 0, nullptr);
    ::_beginthread(threadFunction02, 0, nullptr);
    ::_beginthread(threadFunction03, 0, nullptr);

    // threadFunction03이 끝날 때까지 기다렸다가
    // (3번 스레드는 g_data 출력만 하고 끝남)
    ::WaitForSingleObject(g_hThreadExit03, INFINITE);

    // 3번 스레드가 종료되었으므로, 1번/2번 스레드를 종료시키기 위해 플래그 false
    g_bFlag = false;

    // 1번/2번 스레드가 while 루프를 탈출할 때까지 대기
    ::WaitForSingleObject(g_hThreadExit01, INFINITE);
    ::WaitForSingleObject(g_hThreadExit02, INFINITE);

    // 4) 이벤트 핸들, 크리티컬 섹션 정리
    ::CloseHandle(g_hThreadExit01);
    ::CloseHandle(g_hThreadExit02);
    ::CloseHandle(g_hThreadExit03);

    DeleteCriticalSection(&g_cs);

    std::cout << "Hello World! - End\n";
    return 0;
}

 

C++의 void pointer인 HANDLE을 사용하여 전역변수를 설정하고 CreateEventA라는 메서드로 동기화 작업을 해준다. CreateEventA 라는 메소드는 커널 오브젝트를 생성해주기 때문에, 꼭 나중에 해당 커널 오브젝트들을 꼭 정리해줘야한다. 그래서 마지막에 이벤트 핸들 정리를 위해 closeHandle 함수를 맨 아래에 추가해줬다.

위 코드는 크리티컬 섹션을 사용해 g_data에 대한 접근을 동기화함으로써, 여러 스레드가 번갈아 가면서 g_data를 수정해도 레이스 컨디션이 발생하지 않도록 하였다.

  • T1: g_data = 1000을 반복적으로 대입
  • T2: g_data = 2000을 반복적으로 대입
  • T3: g_data를 총 10번 읽어서 출력

모든 스레드는 EnterCriticalSection(&g_cs)와 LeaveCriticalSection(&g_cs) 사이에서만 g_data를 만지도록 하여, 동시에 여러 스레드가 g_data를 접근하지 못하게 했다.
또한, 각 스레드 종료 시에는 이벤트(SetEvent)로 “나는 끝났다”를 알려주고, 메인 스레드는 WaitForSingleObject로 그 이벤트들을 기다린 뒤에 프로그램을 정상 종료한다.


결과적으로 동시에 읽고 쓰는 동작이 배제되어 안전하게 번갈아 값을 변경하고 읽는 모습을 확인할 수 있다.

 

 

'운영체제' 카테고리의 다른 글

프로세스간 통신  (0) 2025.01.13
CPU 스케줄링  (0) 2025.01.13
Process의 생성과 복사  (0) 2025.01.12
프로세스 휴식, 보류 상태와 문맥  (0) 2025.01.12
프로세스의 상태 변화  (0) 2025.01.12