Home [Linux System Programming] Ch07 스레딩
Post
Cancel

[Linux System Programming] Ch07 스레딩

[Ch07 스레딩]

  • 스레딩은 단일 프로세스 내에서 실행 유닛을 여러 개 생성하고 관리하는 작업을 뜻한다.
  • 스레딩은 Data-race condition과 Deadlock을 통해 어마어마한 프로그래밍 에러를 발생시키는 원인이다.

7.1 바이너리, 프로세스, 스레드

  • 바이너리
    • 저장장치에 기록되어 있는 프로그램
    • 특정 OS와 머신 아키텍처에서 접근할 수 있는 형식으로 컴파일되어 아직 실행되지 않은 프로그램.
  • 프로세스
    • 실행된 바이너리를 표현하기 위한 OS의 추상 개념
    • 메모리에 적재되고 가상화된 메모리와 열린 fd, 연관된 사용자와 같은 커널 리소스 등을 포함
  • 스레드
    • 프로세스 내의 실행 단위로 가상화된 프로세서, 스택, 프로그램 상태 등을 포함.
  • 프로세스는 실행 중인 바이너리이고, 스레드는 OS의 프로세스 스케줄러에 의해 스케줄링될 수 있는 최소한의 실행 단위를 뜻함.
  • 하나의 프로세스는 스레드를 하나 이상 포함한다.
  • 가상 메모리
    • 프로세스가 실제 물리적인 RAM이나 디스크 저장장치에 맵핑된 메모리의 고유한 뷰를 사용할 수 있도록 함.
    • 스레드가 아니라 프로세스와 관련 있음.
      • 각 프로세스는 메모리에 대한 하나의 유일한 뷰를 갖지만 한 프로세스 내의 모든 스레드는 메모리를 서로 공유한다.
  • 가상 프로세서
    • 여러 프로세스 상에서 많은 프로세스가 멀티태스킹 중이라는 사실을 숨김으로써 프로세스가 혼자 실행되고 있다고 착각하게 만든다.
    • 스레드와 관련이 있음.
    • 각각의 스레드는 스케줄이 가능한 독립적인 요소이며, 단일 프로세스가 한번에 여러 가지 일을 할 수 있게 해준다.
  • → 스레드는 프로세스와 마찬가지로 시스템의 프로세서 모두 소유했다는 환상을 가지지만, 가상 메모리의 모든 메모리를 소유했다는 환상을 가지고 있진 않다.
  • 또한 프로세스 내의 모든 스레드는 메모리 주소 공간 전체를 공유한다.

7.2 멀티 스레딩

  • 멀티 스레딩의 장점
  • 프로그래밍 추상화
    • 작업을 나누고 각각 실행 단위로 할당하는 것은 좋은 접근 방법
  • 병렬성
    • 프로세서가 여러 개인 머신에서 효과적으로 병렬성을 구현할 수 있음
  • 응답속도 향상
    • 멀티스레딩을 이용해 오래 실행되는 작업을 워커 스레드에 맡기고 최소한 하나의 스레드는 사용자 입력에 대응하는 작업을 수행
  • 입출력 블록
    • 스레드를 사용하지 않으면 입출력을 블록하면서 전체 프로세스를 멈추게 만든다.
  • 컨텍스트 스위칭
    • 스레드의 전환보다 프로세스 단위의 전환이 훨씬 비싸다.
  • 메모리 절약
    • 스레드는 여러개의 실행단위를 활용하면서도 메모리를 공유하는 방법을 제공함.
    • → 스레드는 멀티 프로세스의 대안이기도 하다.

7.2.1 멀티스레딩 비용

  • 같은 프로세스에 속한 스레드는 리소스를 공유하는데 같은 데이터를 읽거나 쓰는 것이다. 따라서 스레드 동기화는 매우 중요하고 어떻게 동작하는지 이해하고 있어야 한다.
  • 설계 시작부터 반드시 스레딩 모델과 동기화 전략을 고려해야함.

7.2.2 멀티스레딩 대안

  • 지연시간과 입출력상의 장점이 스레드 사용 이유라면?
    • → 다중입출력, 논블록 입출력, 비동기식 입출력을 조합해서 사용 가능
    • 입출력 작업이 프로세스를 블록하지 않도록 함
  • 제대로 된 병렬화가 목표라면?
    • N개의 프로세스를 N개의 스레드처럼 프로세서를 이용하도록 하고 리소스 사용, 컨텍스트 스위칭 비용의 오버헤드를 감수해서 해결 가능
  • 메모리 절약?
    • → 스레드보다 더 제한된 방식으로 메모리를 공유할 수 있는 도구를 리눅스는 제공함.

7.3 스레딩 모델

Untitled

  • 리눅스에서는 1:1스레딩을 사용한다.

7.3.1 사용자 레벨 스레딩

  • N:1 스레딩
  • 스레드가 N개인 프로세스 하나는 단일 커널 프로세스에 맵핑됨.
  • 장점
    • 애플리케이션이 커널의 관여 없이 스스로 어떤 스레드를 언제 실행할지 결정할 수 있으므로 Context switching 비용이 거의 안듬.
  • 단점
    • 하나의 커널 요소가 N개의 스레드를 떠받치고 있기 때문에 여러개의 프로세스 활용을 못하고, 제대로 된 병렬성 제공이 안됨.
  • 사용자 스레드에서 I/O가 하나라도 발생하면 해당 프로세스는 I/O가 풀릴 때 까지 영원히 Block됨.

7.3.2 하이브리드 스레딩

  • N:M

7.3.3 코루틴과 파이버

  • 코루틴과 파이버는 스레드보다 더 작은 실행 단위를 제공함.
  • 코루틴 : 프로그래밍 언어에서 사용
  • 파이버 : 시스템에서 사용되는 용어
  • 리눅스는 이미 빠른 컨텍스트 스위칭 속도로 인해 커널 스레드의 성능을 극한까지 다듬어야 할 필요가 없어서, 코루틴이나 파이버에 대한 네이티브 지원이 없다.

7.4 스레딩 패턴

  • 스레드를 사용하는 애플리케이션을 작성할 때 가장 중요하면서 제일 먼저 해야하는 일은 애플리케이션의 처리과정과 입출력 모델을 결정짓는 스레딩 패턴을 결정하는 것!

7.4.1 연결별 스레드

  • 하나의 작업 단위가 스레드 하나에 할당되는 프로그래밍 패턴.
  • 작업 단위가 실행되는 동안 많아 봐야 하나의 작업이 스레드 하나에 할당됨.
  • 즉, 작업이 완료될 떄까지 실행 하는 패턴
    • 스레드는 연결이나 요청을 받아서 완료될 때 까지 처리, 작업을 완료하면 다른 요청을 받아서 다시 처리
  • 연결별 스레드 모델에서는 연결이 스레드를 소유하기 때문에 입출력 블록킹이 허용됨.
  • 프로세스단에서 생각하면 fork 모델이 이와 같은 패턴을 따름

7.4.2 이벤트 드리븐 스레딩

  • 대부분의 스레드는 많은 시간을 그저 대기 중임.
  • 방법
    • 모든 입출력을 비동기식으로 처리하고 다중 입출력을 사용해서 서버 내 제어 흐름을 관리한다.
    • 입출력 요청이 반환되면 이벤트 루프는 해당 콜백을 대기 중인 스레드로 넘김.

7.5 동시성, 병렬성, 경쟁 상태

  • 동시성
    • 둘 이상의 스레드가 특정 시간에 함께 실행되는 것을 의미한다.
  • 병렬성
    • 둘 이상의 스레드가 동시에 실행되는 것을 의미
  • 동시성은 병렬성 없이 이루어질 수도 있음.
    • 단일 프로세서를 가진 시스템에서의 멀티태스킹
  • 병렬성은 다중 프로세서를 필요로 하는 동시성의 특수한 예

7.5.1 경쟁 상태 (Race Condition)

  • 스레드는 순차적으로 실행되지 않고 실행이 겹치기도 하므로 각 스레드의 실행 순서를 예측할 수 없다.
  • 스레드가 서로 리소스를 공유한다면 문제가 된다.
  • 경쟁 상태
    • 공유 리소스에 동기화되지 않은 둘 이상의 스레드가 접근하려 프로그램의 오동작을 유발하는 상황을 뜻함.
  • 크리티컬 섹션(Critical Section)
    • 경쟁상태가 발생할 수 있기 때문에 반드시 동기화가 되어야 하는 영역

경쟁 상태의 실제 사례

  • 은행 입출금 예제
  • X++ 의 예제
    • 동시에 진행하면 잘못된 결과를 초래할 수 있음.

7.6 동기화

  • 경쟁 상태를 예방하려면 이 Critical Section의 접근을 Mutual Exclusion (상호 배제) 하는 방식으로 접근을 동기화해야 함.
  • Atomic
    • 다른 연산(혹은 연산의 집합)에 끼어들 여지가 없다는 것

7.6.1 뮤텍스

  • 크리티컬 섹션을 원자적으로 만들기 위한 가장 평범한 기법은 크리티컬 섹션 안에서 상호 배제를 구현해서 원자적으로 만들어주는 Lock이다.
  • 락이 있어서 상호 배제를 구현할 수 있으며 Pthread 및 여러 곳에서 뮤텍스 라고 불린다.
  • Mutex. == Binary Semaphore
  • 락은 동시성이라는 스레딩의 장점을 포기하기 때문에 크리티컬 섹션은 가능한 한 최소한으로 잡는 편이 좋다.
  • Notice
    • 락은 코드가 아니라 데이터에 걸어야 한다.

7.6.2 데드락

  • 스레드를 사용하는 이유는 동시성 떄문. → 동시성이 경쟁 상태 유발 → 뮤텍스 사용 → 데드락 유발
  • 데드락
    • 두 스레드가 서로 상대방이 끝나기를 기다리고 있어서 결국엔 둘 다 끝나지 못하는 상태를 의미.

데드락 피하기

7.7 Pthread

  • 리눅스 커널의 스레딩 지원은 clone() 시스템 콜 같은 원시적인 수준뿐임.
  • 하지만 사용자 영역에서 스레딩 라이브러리를 많이 제공한다.

7.7.1 리눅스 스레딩 구현

7.7.2 Pthread API

스레드 관리

  • 스레드 생성, 종료, 조인, 디태치 함수

동기화

  • 뮤텍스와 조건 변수, 배리어를 포함하는 스레드 동기화 함수

7.7.3 Pthread 링크하기

  • glic에서 Pthread를 제공하지만 libpthread 라이브러리는 분리되어 있으므로 링크해줄 필요가 있다.
1
gcc -Wall -Werror -pthread beard.c -o beard

7.7.4 스레드 생성하기

  • Pthread 는 새로운 스레드를 생성하는 함수인 pthread_create()를 제공한다.
1
2
3
4
5
6
#include <pthread.h>

int pthread_create (pthread_t *thread,
                    const pthread_attr_t *attr,
                    void *(*start_routine) (void *),
                    void *arg);
  • 호출이 성공하면 새로운 스레드가 생성되고 start_routine 인자로 명시한 함수에 arg로 명시한 인자를 넘겨서 실행을 시작한다.
  • pthread_t 포인터인 thread가 NULL이 아니라면 여기에 새로 만든 스레드를 나타내기 위해 사용하는 스레드ID를 저장한다.
  • pthread_attr_t 포인터인 attr에는 새로 생성된 스레드의 기본 속성을 변경하기 위한 값을 넘긴다.
    • attr에 NULL을 넘기면 기본 속성
    • 스레드 속성은 스택 크기, 스케줄링 인자, 최초 디태치 상태 등의 특성 결정
  • start_routine 은 다음과 같은 형태를 갖춰야함
    1
    
    void * start_thread (void *arg);
    
  • fork와 유사하게 새로 생성된 스레드는 부모 스레드로부터 대부분의 속성과 기능 그리고 상태를 상속받음.
  • 부모 프로세스 리소스의 복사본을 가지는 fork와는 다르게 스레드는 부모 스레드의 리소스를 공유한다.
    • 프로세스의 주소 공간.
    • 시그널 핸들러와 열린 파일을 공유
  • gcc 로 컴파일 할 때 -pthread 플래그를 넘겨야함.
  • 예제

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    pthread_t tread;
    int ret;
    
    ret = pthread_create (&thread, NULL, start_routine, NULL);
    if (!ret) {
            errno = ret;
            perror("pthread_create");
            return -1;
    }
    
    /* a new thread is created and running start_routine concurrently ... */
    

7.7.5 스레드 ID

  • 스레드 ID (TID) 는 프로세스 ID(PID)와 유사하다.
  • PID를 리눅스 커널에서 할당한다면 TID는 Pthread 라이브러리에서 할당한다.

    1
    2
    3
    
    #include <pthread.h>
    
    pthread_t pthread_self (void);
    
  • pthread_create() 호출이 성공하면 thread 인자에 저장된다.
  • pthread_self() 함수를 이용해서 자신의 TID를 얻어올 수 있음.

스레드 ID 비교하기

  • Pthread 표준은 pthread_t 가 산술 타입이기를 강제하지 않으므로 == 연산자가 동작하리라 보장할 수 없음.
  • 따라서 비교 하려면 특수한 인터페이스를 사용해야한다.
1
2
3
#include <pthread.h>

int pthread_equal (pthread_t t1, pthread_t t2);
  • 예제

    1
    2
    3
    4
    5
    6
    7
    
    int ret;
    
    ret = pthread_equal(thing1, thing2);
    if (ret != 0)
            printf("The TIDs are equal!\n");
    else
            printf("The TIDs are unequal!\n");
    

7.7.6 스레드 종료하기

  • 스레드 종료는 한 스레드가 종료되도 그 프로세스 내의 다른 스레드는 계속 실행된다는 점만 제외하면 프로세스 종료와 비슷함.
  • 스레드가 종료되는 상황
    • start_routine 함수가 반환한 경우. 이는 main() 함수가 끝까지 실행된 상황과 비슷
    • pthread_exit() 함수를 호출한 경우
    • pthread_cancel() 함수를 통해 다른 스레드에서 중지시킨 경우
  • 위는 관계된 스레드 하나만 종료됨. 아래는 프로세스 내 모든 스레드가 종료되어 그 프로세스도 종료되는 경우
    • 프로세스의 main() 함수가 반환
    • 프로세스가 exit() 호출
    • 프로세스가 execve() 호출로 새로운 바이너리를 실행한 경우

스스로 종료하기

  • start_routine 을 끝까지 실행하면 스레드 스스로 종료함.
  • start_routine 함수가 몇 번의 호출을 타고 들어간 콜 스택 깊숙한 곳에서 스레드를 종료시켜야 할 때는 아래의 시스템 콜 사용
1
2
3
#include <pthread.h>

void pthread_exit (void *retval);

다른 스레드 종료하기

  • pthread_cancel 함수를 통해 다른 스레드를 취소시켜 종료할 수 있음.
1
2
3
#include <pthread.h>

int pthread_cancel (pthread_t thread);
  • 실제 종료는 비동기적으로 일어남.
  • 스레드가 취소될지, 또 언제 실행될지는 조금 복잡함.
    • 스레드의 취소 상태는 가능일 수도 있고, 불가능일 수도 있다.
    • 기본값은 취소 가능임.
  • 스레드의 취소 상태는 pthread_setcancelstate() 를 통해 변경할 수 있음

    1
    2
    3
    
    #include <pthread.h>
    
    int pthread_setcancelstate (int state, int *oldstate);
    
    • 스레드의 취소상태가 state 값으로 설정되고 이정 상태는 oldstate에 저장됨.
    • state
      • PTHREAD_CANCEL_ENABLE
      • PTHREAD_CANCEL_DISABLE
  • 스레드의 취소 타입

    • 비동기
      • 취소 요청이 들어온 이후에 언제든지 스레드를 종료시킬 수 있음
      • 특정한 상황에서만 유용함
        • 프로세스를 정의되지 않은 상태로 남겨두기 때문.
        • 스레드가 공유 리소스를 사용하지 않고 시그널 세이프한 함수를 호출한 경우에만 사용해야함
    • 유예
      • 기본 값
    • 취소 타입 변경 함수

      1
      2
      3
      
      #include <pthread.h>
      
      int pthread_setcanceltype (int type, int *oldtype);
      
      • type
        • PTHREAD_CANCEL_ASYNCHRONOUS
        • PTHREAD_CANCEL_DEFERRED
  • 종료 예제

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    int unused;
    int ret;
    
    ret = pthread_setcancelstate (PTHREAD_CANCEL_ENABLE, &unused);
    if (ret) {
            errno = ret;
            perror ("pthread_setcancelstate");
            return -1;
    }
    
    ret = pthread_setcanceltype (PTHREAD_CANCEL_DEFERRED, &unused);
    if (ret) {
            errno = ret;
            perror ("pthread_setcanceltype");
            return -1;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    int ret;
    
    /* `thread' is the thread ID of the to-terminate thread */
    ret = pthread_cancel (thread);
    if (ret) {
            errno = ret;
            perror ("pthread_cancel");
            return -1;
    }
    
    • 취소 상태를 가능으로 바꾸고, 취소 타입은 취소 유예로 설정하는 예제

7.7.7 스레드 조인과 디태치

  • 스레드 생성과 종료는 쉽지만, wait() 함수와 마찬가지로 모든 스레드의 종료를 동기화 해야한다. 이를 스레드 조인이라고 함.

스레드 조인

  • 조인은 스레드가 다른 스레드가 종료될 때까지 블록되도록 한다.
1
2
3
#include <pthread.h>

int pthread_join (pthread_t thread, void **retval);
  • 호출한 스레드는 thread 로 명시한 스레드가 종료될 때까지 블록한다.
    • 해당 스레드가 이미 종료되었다면 pthread_join() 은 즉시 반환된다.
  • 스레드가 종료되면 호출한 스레드가 깨어나고 retval이 NULL이 아니라면 그 값은 종료된 pthread_exit()에 넘긴 값이거나 start_routine 에서 반환한 값이다.
  • 스레드 조인은 다른 스레드의 라이프 사이클에 맞춰 스레드의 실행을 동기화하는 것이다.
  • Pthread 의 모든 스레드는 서로 동등하므로 어떤 스레도도 조인 가능하다.
  • 하나의 스레드는 여러 스레드를 조인할 수 있지만, 하나의 스레드만 다른 스레드에 조인을 시도해야 한다.
  • 예제

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    int ret;
    
    /* join with `thread' and we don't care about its return value */
    ret = pthread_join (thread, NULL);
    if (ret) {
            errno = ret;
            perror ("pthread_join");
            return -1;
    }
    

스레드 디태치

  • 부모 프로세스에서 wait() 을 호출하기전까지 자식 프로세스가 시스템 리소스를 잡아먹는 것과 마찬가지로 스레드도 조인이 되기 전까지 시스템 리소스를 잡아먹고 있으므로 조인을 할 생각이 없는 스레드는 디태치해두어야 한다.
1
2
3
#include <pthread.h>

int pthread_detach (pthread_t thread);

7.7.9 Pthread 뮤텍스

뮤텍스 초기화하기

  • 뮤텍스는 pthread_mutex_t 객체로 표현된다.
1
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

뮤텍스 락 걸기

1
2
3
#include <pthread.h>

int pthread_mutex_lock (pthread_mutex_t *mutex);
  • 호출이 성공하면 mutex로 지정한 뮤텍스의 사용이 가능해질 때까지 호출한 스레드를 블록한다.
  • 해당 뮤텍스가 사용 가능한 상태가 되면 호출한 스레드가 깨어나고, 이 함수는 0을 반환한다.

뮤텍스 해제하기

1
2
3
#include <pthread.h>

int pthread_mutex_unlock (pthread_mutex_t *mutex);
This post is licensed under CC BY 4.0 by the author.