CS/Operating System

5. 멀티 쓰레딩(Multi Threading)

공부중인학생 2023. 10. 17. 09:43

굉장히 큰 matrix를 multiplication 한다면 각각의 column 벡터와 row 벡터의 연산은 독립적 입니다. 이 경우 병렬로 연산을 진행하면 좋지만 지금까지 배운 내용으로 병렬처리를 진행하려면 프로세스 단위로 병렬처리를 진행해야 합니다.

 

프로세스는 stack segment, heap segment, data segment, code segment 등 여러 정보를 담고 있는 무거운 존재이기 때문에 병렬처리를 위해서 대량으로 생성하게 된다면 메모리 오버헤드가 굉장히 커지게 됩니다.

 

 

 

과거에도 과학적 계산들을 위해서 많은 컴퓨팅 파워가 필요했습니다. 그래서 병렬처리를 진행하기 위해서 하나의 프로세스를 컨텍스트와 쓰레드로 나누어 문제를 해결했습니다. 컨택스트 같은 경우에는 메모리 컨텍스트, 하드웨어 컨텍스트, 시스템 컨텍스트 등 하드웨어 리소스를 의미합니다.

 

Resource ownership을 담당하는 컨택스트 부분과 dispatch를 담당하는 컨텍스트로 분리를 시켰습니다. resource of ownership의 단위인 프로세스 컨택스트(하드웨어 리소스 부분)와 실행(execution) 단위인 쓰레드를 분리시킨 것입니다. 분리된 실행 단위를 쓰레드 또는 lightweight process라고 부르고, 나머지 resource ownership의 단위를 프로세스나 task라고 불렀습니다.

 

 

 

쓰레드는 프로세스와 동일하게 state를 가지며 state에 따라 동작합니다. 프로세스가 실행되기 위해서는 런타임 스택이 필요했습니다. 마찬가지로 쓰레드가 실행되기 위해서도 런타임 스택이 필요하기 때문에 예외적으로 런타임 스택이 쓰레드에 별도로 할당이 됩니다. (컨텍스트는 공유가 됩니다.)

 

    - 쓰레드가 instruciton을 실행하려면 function call이 이루어져야 하고 function call에서 리턴하려면 런타임 스택이 필요하기 때문에!

 

그리고 각각의 쓰레드들은 스케줄링이 되기 때문에 pre-thread static memory에 스케줄링에 필요한 정보들을 저장합니다. PCB처럼 TCB가 생기는 것입니다.

 

 

 

위 이미지의 왼쪽 부분이 1980년 중반 이전의 unix os의 프로세스 모델입니다. allocationed resource와 execution stream이 하나의 단위인 것을 알 수 있습니다. 오른쪽이 multi-threading process module입니다. PCB나 memory segment 등은 모든 쓰레드들이 공유하지만 자기만의 유저 스택과 커널 스택 그리고 TCB를 가지게 됩니다.

 

쓰레드는 swap out 같은 프로세스 단위에서 일어나는 동작을 따라하지 못합니다. 만약 쓰레드 단위에서 swap out이 일어나면 전체의 프로세스가 swap out이 되게 됩니다. 또 다른 특징으로는 쓰레드는 하드웨어 리소스를 공유하는데 이로 인해서 IPC가 잘 이루어집니다.

 

과거에는 parallel task를 하나 생성하게 되면 여러 개의 프로세스를 만들어야되서 구현이 어려웠고 fork() operation도 비용이 비싸서 비효율적이었습니다. 지금은 쓰레드 덕분에 비용이 저렴해졌습니다.

 

C로 single thread programming을 할 때 진입점은 main function 입니다. multi thread programming 코드를 작성할 때는 수많은 쓰레드들이 존재하는데 각 쓰레드의 진입점은 함수가 됩니다. 즉 함수가 쓰레드를 구성하는 단위가 되는 것으로 single thread에서는 main function과 일대일 매핑이 되고 multi thread에서는 다른 function과 일대일 매핑이 되는 것입니다.  

 

 

 

C언어 프로그래밍을 할 때 main function에서 시작하고 끝나는 1개의 쓰레드만 존재하며 multi thread programming을 하고 싶다면 중간중간에 API를 통해 새로운 쓰레드를 생성해야 합니다. 위 테이블 중 첫 번째 부분인 pthread_create()는 C에서 새로운 쓰레드를 만들어주는 API입니다.

 

    - pthread_create()의 파라미터로는 함수의 시작 주소를 표현해주는 function pointer가 필요합니다.

    - 사용자가 쓰레드를 생성하고 소멸시키는 스케줄링 기능을 API가 제공하는 것입니다.

    - POSIX는 multi threading을 지원하는 API를 표준화한 것입니다.

 

pthread_exit은 쓰레드 수행을 종료시키는 것이고 pthread_join은 쓰레드 간의 synchronization이나 coorination과 관련된 함수입니다. pthread_yield는 non-preemptive하게 CPU를 양보할 때 사용하는 함수입니다.

 

 

 

프로세스의 life cycle과 비슷하게 쓰레드의 life cycle을 도식화한 것입니다. 하나의 쓰레드로 시작되며 pthread_create를 통해서 쓰레드를 생성하고 pthread_yield로 CPU를 양보했다 다시 스케줄링되어 동작되다 pthread_exit을 통해 종료가 되는 과정을 보여줍니다. 메인 쓰레드는 생성한 쓰레드들이 전부 종료되기 전까지 기다려야 하는데 이때 호출하는 함수가 pthread_join입니다.

 

 

// C코드 예시

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <assert.h>
 
#define NUM_THREADS     5
 
void *ThreadCode(void *argument)
{
   int tid;
 
   tid = *((int *)argument);
   printf("Hello World! It's me, thread %d!\n", tid);
 
   /* optionally: insert more useful stuff here */
 
   return NULL;
}

 

ThreadCode 함수는 생성된 쓰레드의 아이디를 주고 간단한 출력을 진행하는 코드입니다. ThreadCode는 쓰레드의 바디를 구성하는 instruction의 시작 주소인 function pointer를 정의하기 위한 declaration입니다.

 

int main(void)
{
   pthread_t threads[NUM_THREADS];
   int thread_args[NUM_THREADS];
   int rc, i;

    /* create all threads */
   for (i=0; i<NUM_THREADS; ++i) {
      thread_args[i] = i;
      printf("In main: creating thread %d\n", i);
      rc = pthread_create(&threads[i], NULL, ThreadCode, (void *)&thread_args[i]);
      assert(0 == rc);
   }
    /* wait for all threads to complete */
   for (i=0; i<NUM_THREADS; ++i) {
      rc = pthread_join(threads[i], NULL);
      assert(0 == rc);
   }
   exit(EXIT_SUCCESS);
}

 

main funtion에서 pthread_t 타입의 threads란 어레이를 할당받는데 이건 Thread Control Block(TCB)을 의미합니다. PCB는 OS가 커널 메모리에 두는 data structure인 반면에 TCB는 main function이 가지고 있는 자료구조입니다. pthread_create에는 TCB의 시작 주소와 쓰레드 바디의 엔트리 주소(function pointer), 쓰레드 id 이렇게 파라미터를 넣게 됩니다. 첫 번째 for loop를 통해 다섯 개의 쓰레드가 생성되면 그다음 pthread_join을 통해 모든 쓰레드가 exit 하기 전까지 대기하게 됩니다.

 

    - 1번째 쓰레드가 exit 하면 두 번째 for loop가 1번 도는 방식으로 다섯 번 돌면 수행이 종료됩니다.

 

 

 

 

이번에는 multi threading이 os 내부에서 어떻게 구현되는지를 알아보겠습니다. multi threading을 구현한다는 것은 pthread 라이브러리 안에 있는 함수들을 전부 구현한다는 것을 의미합니다. 3가지 방식으로 구현할 수 있습니다.

 

  1. User level library function으로 구현
  2. Kernel function로 구현
  3. 1번과 2번 사이 중간 형태로 구현 (두 가지의 장점만 취하는 형태)

 

 

 

유저 레벨 라이브러리 구현에 대해서 알아보겠습니다. 이 경우 100% 유저 레벨로 구현되기 때문에 커널이 유저 코드를 볼 수 없습니다. 그래서 커널은 쓰레드가 있다는 사실을 인지하지 못하고 해당 프로세스를 single thread process로 보게 됩니다. 이런 유저 레벨 쓰레드는 create가 굉장히 저렴하지만 스케줄링을 진행할 때 문제가 생깁니다. 

 

system call로 구현하게 되면 system interrupt handler과 kernel function 호출/리턴이 일어나기 때문에 복잡하며 비용이 비싸집니다. 그리고 커널 레벨로 구현하는 경우에는 OS를 수정해야하기 때문에 1980년대 중반에는 유저 레벨 라이브러리로 구현을 했습니다.

 

 

 

유저 레벨 라이브러리 구현에도 단점이 있는데 blocking anomaly라고 해서 유저 레벨에서 만들어진 쓰레드 중 하나가 blocking system call을 한 경우, 예를 들어 read() system call을 하면 하나의 쓰레드만 block 되고 waiting 해야 하지만 OS가 볼 땐 다른 쓰레드가 존재하는 것을 모르기에 전체 프로세스를 blocking 시켜버리는 문제가 발생합니다.

 

그 외에도 preemptive scheduling을 못한다는 문제가 있습니다. hardware interrupt가 발생하여 프로세스(+ 모든 쓰레드)가 멈춘 다음 다시 동작할 때 어떤 쓰레드가 interrupt를 받아 다시 동작하는지 알 수가 없는 문제가 생기게 됩니다. (hardware interrupt를 사용한 asynchronous 한 preemptive scheduling이 불가능 합니다)

 

 

kernel level implementation의 형태

 

kernel level implementation의 장단점

 

커널 레벨로 구현하는 경우 pthread_create 함수가 system call로 구현된 것입니다. OS가 쓰레드들을 인지하기 때문에  preemptive scheduling이나 blocking anomaly가 해결이 됩니다. 단점으로는 비용이 커진다는 점과 OS 코드를 수정하다는 점이 존재합니다.

 

 

 

마지막 3번째 구현 방법은 유저 레벨 코드에게 커널 레벨 쓰레드를 만드는 API를 구현하여 유저 레벨 쓰레드와 커널 레벨 쓰레드를 연결시키는 system call을 만들어 줍니다. 위의 예시로 나온 시스템의 경우 멀티 프로세스 시스템으로 CPU(그림에서 P)가 2개 존재합니다. Light Weight Process(LWP)가 유저 레벨 쓰레드와 커널 레벨 쓰레드를 연결시키는 역할을 합니다.

 

도식화된 이미지의 왼쪽 프로세스를 보면 2개의 커널 레벨 쓰레드가 3개의 유저 레벨 쓰레드를 실행합니다. 오른쪽은 커널 레벨 쓰레드 1개와 유저 레벨 쓰레드 1개가 연결된 형태입니다. 이런 프로그래밍 모델을 제공하는 대표적인 OS가 솔라리스 OS로 multi process allocation을 쉽게 만들어 줍니다. 

'CS > Operating System' 카테고리의 다른 글

7. 스케줄링(Scheduling)  (2) 2023.10.31
6. 리소스(Resource)  (0) 2023.10.24
4. Process (creation, termination, context switching)  (0) 2023.10.12
3. 스택(Stack)  (2) 2023.10.01
2. System bus, Duel mode  (0) 2023.09.30