스레딩

sesame·2022년 1월 27일
0

교육

목록 보기
23/46

스레딩

단일 프로세스 내에서 실행 유닛을 여러 개 생성하고 관리하는 작업
스레딩은 data-race(데이터 경쟁) 상태와 데드락을 통해 어마어마한 프로그래밍 에러를 발생시키는 원인

  • 바이너리
    저장장치에 기록되어있는 프로그램
    특정 운영체제와 머신 아키텍처에서 접근할 수 있는 형식으로 컴파일되어 실행할 준비가 된, 하지만 아직 실행되지는 않은 프로그램

  • 프로세스
    실행된 바이너리를 표현하기 위한 운영체제의 추상 개념으로, 메모리에 적재되고 가상화된 메모리와 열린 파일 디스크립터, 연관된 사용자와 같은 커널 리소스 등을 포함

  • 스레드
    프로세스 내의 실행 단위로 가상화된 프로세서, 스택, 프로그램 상태 등을 포함

프로세스는 실행중인 바이너리, 스레드는 운영체제의 프로세스 스케줄러에 의해 스케줄링 될 수 있는 최소한의 실행 단위

싱글 스레드

하나의 프로세스는 스레드를 하나 이상 포함
어떤 프로세스의 스레드가 하나라면 그 프로세스는 단일 실행 단위를 가지며 한번에 하나만 실행한다는 뜻

최신 운영체제는 가상 메모리와 가상 프로세서라는 두가지 추상 개념을 제공
이 둘은 실행 중인 각 프로세스가 머신의 리소스를 독점하고 있다고 착각하도록 만든다.

가상 메모리(스레드가 아니라 프로세스와 관련)
프로세스가 실제 물리적인 RAM이나 디스크 저장장치(페이징 통해)에 맵핑된 메모리의 고유한 뷰를 사용할 수 있도록 한다.(프로세스 내의 모든 스레든느 메모리를 서로 공유)

시스템의 RAM은 실제로는 100여개의 실행중인 다른 프로세스의 데이터를 담고 있을 수는 있으나 각 프로세스는 가상 메모리를 통해 메모리 전체를 소유했다고 착각한다.

가상 프로세서(스레드와 관련)
여러 프로세서상에서 많은 프로세스가 멀티태스킹 중이라는 사실을 숨김으로써 프로세스가 시스템에서 혼자 실행되고 있다고 착각하게 만든다.

각각의 스레드는 스케줄이 가능한 독립적인 요소이며 단일 프로세스가 한번에 여러가지 일을 할 수 있게 해준다.

멀티 스레딩

  • 프로그래밍 추상화
    작업을 나누고 각각 실행단위(스레드)로 할당하는 것

  • 병렬성
    멀티 프로세서에서 효과적으로 병렬 처리

  • 응답속도 향상
    멀티 스레딩을 이용하면 오래 실행되는 작업을 worker 스레드에 맡기고 최소한 하나의 스레드는 사용자의 입력에 대응해서 UI 작업을 수행할 수 있도록 남겨두면 된다.

  • 입출력 블록
    스레드를 사용하지 않으면 입출력 블록하면서 전체 프로세스를 멈추게 만듬
    이는 처리량과 응답시간이라는 두 측면에서 유익하지 않다.
    멀티 스레드 프로세스라면 개별 스레드가 블록되어 입출력을 기다리더라도 다른 스레드가 계속해서 진행할 수 있다.

  • 컨텍스트 스위칭
    프로세스 단위의 컨텍스트 스위칭보다 저렴

  • 메모리 절약

컨텍스트 스위칭: CPU가 어떤 프로세스를 실행하고 있는 상태에서 인터럽트에 의해 다음 우선 순위를 가진 프로세스가 실행되어야 할 때 기존의 프로세스 정보들은 PCB에 저장하고 다음 프로세스의 정보를 PCB(Process Control Block)에서 가져와 교체하는 작업을 컨텍스트 스위칭이라 한다.

컨텍스트 스위칭: 프로세스 vs 스레드
프로세스 내의 스레드 간 컨텍스트 스위칭 비용이 높지 않다는 점
리눅스에서 프로세스 내 스레드간의 컨텍스트 스위칭 비용은 커널 내부로 진입했다가 다시 빠져나오는 정도로 거의 0에 가깝다.
프로세스 비용이 비싼 것이 아니고 스레드 값이 더 싼것

멀티 스레딩 비용

가상화된 프로세서는 복수인데 반해 가상 메모리는 하나라는 점이 스레드의 존재 이유인 동시에 단점
멀티 스레드 프로그램을 이해하고 디버깅하기는 무척 어려우니 시스템 설계 시작부터 반드시 스레딩 모델과 동기화 전략을 고려해야한다.

멀티 스레딩 대안

  • 지연시간과 입출력상의 장점이 목표
    : 다중 입출력, 논블록 입출력, 비동기식 입출력을 조합해 사용 가능
    (이 기법은 작업이 프로세스를 블록하지 않도록 함)

  • 병렬화가 목표
    : N개의 프로세스를 N개의 스레드처럼 프로세서를 이용하도록 하고 약간의 리소스 사용과 컨텍스트 스위칭 비용의 오버헤드를 감수해서 해결할 수 있다.

  • 메모리 절약이 목표
    : 리눅스는 스레드보다 더 제한된 방식으로 메모리를 공유할 수 있는 도구를 제공

--멀티 코어가 유행하면서 스레드 사용이 점점 늘고 있음

스레딩 모델

  • 커널 레벨 스레딩 - 커널이 제공하는 것과 사용자가 사용하는 것이 1:1의 관계이므로 1:1 스레딩이라고도 함

  • 사용자 레벨 스레딩 - 사용자영역에서 스레드를 구현하고, 스레드가 N개인 프로세스 하나는 단일 커널 프로세스로 맵핑되므로 N:1이라고도 함.

  • 하이브리드 스레딩 - 커널은 네이티브 스레드 개념을 제공하고, 사용자 영역에서도 역시 사용자 스레드를 구현함. N:M 스레딩으로 N개의 사용자 스레드를 M개의 커널 스레드와 맵핑함

  • 코루틴과 파이버 - 스레드보다 더 작은 실행 단위를 제공
    코루틴은 프로그래밍 언어에서 사용되는 용어, 파이버는 시스템에서 사용되는 용어
    리눅스는 빠른 컨텍스트 스위칭 속도로 인해 코루틴이나 파이버에 대한 네이티브 지원이 없는데, Go 언어는 언어수준에서 코루틴과 유사한 고루틴을 제공함

스레딩 패턴

연결별 스레드 - 하나의 작업 단위가 스레드 하나에 할당됨
작업이 완료될 때까지 실행하는 패턴
스레드는 연결이나 요청을 받아서 완료될 때까지 처리하고, 그 스레드가 작업을 완료하면 다른 요청을 받아서 다시 처리할 수 있게 되는 모델

차이점
연결별 스레드 모델에서는 연결(혹은 요청)이 스레드를 소유하기 때문에 입추력 블록킹이 헝요
스레드가 블록되면 해당 블록킹을 유발한 연결만 멈추게 된다.

수천 개의 연결을 처리할 수 있는 리소스를 가지고 있다해도 여전히 많은 스레드를 동시에 실행하는 데는 제한된 확장성을 지닌다.
대안을 찾으면서 시스템 설계자는 대부분의 스레드가 파일을 읽거나, 데이터베이스에서 결과가 반환되기를 기다리거나, 아니면 원격 프로시저호출을 시도하는 등 만으 ㄴ시간을 그저 대기 중이라는 사실을 깨닫는다.

이런 이유로 이벤트 드리븐 스레딩 탄생

이벤트 드리븐 스레딩 - 모든 입출력은 비동기식으로 처리하고, 다중 입출력을 사용해서 서버 내 제어 흐름을 관리한다. 이 모델에서는 요청을 처리하는 과정이 일련의 비동기식 입출력 요청으로 변환되어 관련된 콜백과 연결된다.

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

동시성 - 둘 이상의 스레드가 특정 시간에 함께 실행되는 것을 의미함

병렬성 - 둘 이상의 스레드가 동시에 실행되는 것을 의미함

경쟁상태 - 스레딩에서 겪게되는 가장 큰 수난. 스레드는 순차적으로 실행되지 않고 실행이 겹치기도 하므로 각 스레드의 실행 순서를 예측할 수 없다.
일반적으로 경쟁상태란 공유 리소스에 동기화되지 않은 둘 이상의 스레드가 접근하여 프로그램의 오동작을 유발하는 상황
(공유 리소스는 시스템의 하드웨어나, 커널 리소스, 메모리에 있는 데이터 등 무엇이든 될 수 있음)

ex) 현금자동입출금기
잔고가 갱신되고 현금이 출금되기 전에 예금 확인이 동시에 일어난다면: 출금이 두번 이루어질 수 있음
-- 함수를 동기화해서 여러 스레드에서 동시에 호출하더라도 잔고를 조회하고 인출하는 과정이 단일 트랜잭션안에서 이루어져야함

동기화

기본적으로 경쟁 상태는 정상적인 프로그램의 동작을 위해 스레드가 실행 중 끼어들지 않아야하는 영역인 크리티컬 섹션에 발생
상호 배제(Mutual Exclusion)하는 방식으로 접근을 동기화 해야 함

뮤텍스(Mutex) - lock(), unlock()을 통해 락관리
락은 동시성이라는 스레딩의 장점을 포기하기 때문에 크리티컬 섹션은 가능한 한 최소한으로 잡는 편이 좋다.

멀티스레드 프로그래밍에서 가장 중요한 패턴은 코드가 아니라 데이터에 락을 걸어야한다는 점이다. 공유 데이터에 관련된 락을 두고 이 데이터에 접근할 때는 항상 관련 락을 얻은 후에 접근해야한다.

데드락이란 두 스레드가 서로 상대방이 끝나기를 기다리고 있어서 결국엔 둘 다 끝나지 못하는 상태를 말한다.

화성에서 발생한 멀티스레딩 버그
기상정보 수집 스레드(낮은 우선순위), 지구와 통신 스레드(중간 우선순위), 탐사선 전체의 저장장치를 관리하는 스토리지 스레드(가장 높은 우선순위)
먼저 뮤텍스를 얻은 후에 스토리지 서브시스템으로 기상데이터를 기록하고 뮤텍스를 해제
스토리지 스레드 역시 스토리지 서브시스템을 관리하기 전에 뮤텍스를 얻어야한다. 뮤텍스를 얻지 못하면 기상 정보 스레드가 뮤텍스를 해제할 때까지 대기
기상정보 스레드가 뮤텍스를 잡고 있고 스토리지 스레드가 뮤텍스를 기다리는 중에 통신 스레드가 가끔씩 깨어난다.
통신 스레드는 기상정보 스레드보다 우선순위가 높기 때문에 기상정보 스레드를 버리고 통신 스레드가 실행된다. 화성은 멀리 있기 때문에 통신 스레드는 꽤 오랫동안 실행된다...
따라서 통신 스레드가 동작하는 동안 기상정보 스레드가 멈춰있기 때문에 스토리지 스레드가 필요로하는 뮤텍스를 전달할 수 없어서 스토리지 스레드도 대기하게 된다.
결국 우선순위가 낮은 통신 스레드가 간접적으로 우선순위가 높은 스토리지 스레드 대신 실행되었다. 이는 우선순위 역전으로 알려진 문제다.
이 문제는 리소스를 쥐고 있는 프로세스의 우선순위가 해당 리소스를 기다리고 있는 프로세스 중 가장 높은 우선순위를 상속받도록 하는 우선순위 상속이라는 기법을 통해 해결했다.
이 사례에서 낮은 우선순위의 기상정보 스레드는 뮤텍스를 잡고있는 동안에는 가장 높은 우선순위를 가지는 스토리지 스레드의 우선순위를 상속받음

데드락 피하기
멀티스레딩을 처음 배울 때 부터 락을 설계하는 것이 안전하고 유일한 방법
ABBA 데드락
한 스레드가 A뮤텍스를 잡고 있는 상태에서 B뮤텍스를 기다리고 있고, 다른 스레드는 B뮤텍스를 잡고 A뮤텍스를 기다리고 있는 데드락을 뜻함
-- A뮤텍스를 B뮤텍스 보다 먼저 얻어야 한다.

Pthread

POSIX 스레드를 줄여서 Pthread라고 한다.
스레드 관리 - 스레드 생성, 종료, 조인, 디태치 함수 등
동기화 - 뮤텍스와 조건 변수, 배리어(barrier)를 포함하는 스레드 동기화 함수 등

스레드 함수

새로운 스레드 생성

pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(start_routine) (void*), void *arg);

start_routine: 인자로 명시한 함수에 arg로 명시한 인자를 넘겨서 실행을 시작

형태
void start_thread(void arg);

pthread_t 포인터인 tread: 가 NULL이 아니라면 여기에 새로 만든 스레드를 나타내기 위해 사용하는 스레드ID를 저장

pthread_attr_t *attr에는 새로 생성된 스레드의 기본 속성을 변경하기 위한 값을 넘김
NULL을 넘기면 기본속성

스레드 속성
스택 크기, 스케줄링 인자, 최초 디태치 상태 등 스레드의 여러가지 특성 결정

return: 성공시 새로운 스레드 생성, 에러시 0이 아닌 에러 코드(errno사용하지 않고)를 직접 반환하며 이 경우 thread의 내용은 정의되지 않았다.

fork()와 유사하게 부모 스레드로부터 대부분의 속성과 기능, 상태를 상속받음
fork()는 부모 프로세스의 리소스의 복사본을 가지지만 스레드는 부모 스레드의 리소스를 공유한다.

스레드 ID

pthread_t pthread_self(void);

자신의 TID 얻어올 수 있음

스레드 ID 비교하기

int pthread_equal(thing1, thing2);

return: 두 스레드 ID가 동일하면 0이 아닌 값 반환, 두 스레드 ID가 다르면 0반환
이 함수는 실패하지 않는다

스레드 종료하기

스레드 종료는 한 스레드가 종료되도 그 프로세스 내의 다른 스레드는 계속 실행된다는 점만 제외하면 프로세스 종료와 비슷하다.

종료되는 경우

  • start_routine 함수가 반환한 경우, main()함수가 끝까지 실행된 상황과 비슷
  • pthread_exit() 함수를 호출한 경우, exit()과 비슷
  • pthread_cancle() 함수를 통해 다른 스레드에서 중지시킨 경우, kill()통해 SIGKILL 시그널 보낸 경우와 비슷

이 세가지 경우는 관계된 스레드 한개만 종료

  • 프로세스의 main()함수 반환경우
  • 프로세스가 exit() 호출로 종료된 경우
  • 프로세스가 execve() 호출로 새로운 바이너리를 실행한 경우
//스스로 종료하기
//start_routine을 끝까지 실행하면 보통 스레드를 스스로 종료하게 만들 수 있다.
//start_routine에서 몇번의 호출을 타고 들어간 콜 스택 깊숙한 곳에서 스레드를 종료시켜야하는 경우도 종종 있다.
void pthread_exit(void *retval);

retval: 그 프로세스가 종료되기를 기다리는 다른 스레드에 전달할 값
이 함수는 실패하지 않는다.

다른 스레드 종료하기

int pthread_cancel(pthread_t thread);

성공시 thread로 명시한 스레드ID를 가진 스레드에 취소 요청 보낼 수 있음
return: 호출 성공시 0반환, 실패시 thread가 유효하지 않음을 나타내는 ESRCH 반환

스레드가 취소될지, 또 언제 실행될지는 조금 복잡하다. 스레드의 취소 상태는 가능일 수도 있고 불가능일 수도 있다. 기본값은 취소가능
만일 스레드 취소 상태가 불가능이라면 해당 요청은 취소 상태가 가능으로 바뀔때까지 큐에 대기
그렇지 않다면 스레드가 취소되는 시점은 취소타입에 따라 결정

스레드의 취소상태는 pthread_setcancelstate()로 변경할 수 있다.

int pthread_setcancelstate(int state, int *oldstate);

return: 성공시 스레드의 취소상태가 state 값으로 설정, 이전 상태는 oldstate에 저장, 0반환
에러시 state값이 유효하지 않음을 나타내는 EINVAL 반환

state
PTHREAD_CANCLE_ENABLE: 취소 가능
PTHREAD_CANCLE_DISABLE: 취소 불가능

스레드 취소 타입은 비동기 혹은 유예인데 취소 유예가 기본값이다.

  • 비동기 취소는 취소 요청이 들어온 이후에 언제든지 스레드를 종료시킬 수 있다.
  • 취소 유예는 Pthread나 C 라이브러리 함수 내에서 외부의 취소 요청에 대해 안전한 특정 시점에서만 종료시킬수 있다.
    비동기적인 취소는 특정한 상황에서만 유용한데 그 이유는 프로세스를 정의되지 않은 상태로 남겨두기 때문
    ex) 취소된 스레드가 크리티컬 섹션안에 있다면 비동기적인 취소는 스레드가 공유 리소스를 사용하지 않고 시그널 세이프한 함수를 호출한 경우에만 사용해야한다.
    취소 타입은 pthread_setcancletype()함수로 변경 가능
int pthread_setcanceltype(int type, int *oldtype);

type: 이 타입으로 설정
oldtype: 여기에 전 타입 기록

type
PTHREAD_CANCLE_ASYNCHRONOUS: 비동기 취소
PTHREAD_CANCLE_DEFERRED: 취소 유예 타입

return: 성공시 0반환, 에러시 EINVAL
기본값: 취소가능 상태, 취소타입 유예

스레드 조인과 디태치

스레드 생성과 종료는 쉽게 할 수 있지만, 프로세스에서 wait()함수와 마찬가지로 어떤 식으로든 스레드의 종료를 동기화해야한다. 이를 스레드 조인이라고 한다.

int pthread_join(pthread_t thread, void **retval);

성공시 호출한 스레드는 thread로 명시한 스레드가 종료될 때까지 블록된다.
해당 스레드가 이미 종료되었다면 pthread_join()은 즉시 반환
스레드가 종료되면 호출한 스레드가 꺠어나고 retval이 NULL이 아니라면 그 값은 종료된 스레드가 조인된것이다.
하나의 스레드가 여러 스레드를 조인할 수 있지만, 하나의 스레드만 다른 스레드에 조인을 시도해야 한다. 동시에 여러 스레드가 같은 스레드에 조인을 시도하면 안 된다.

return: 성공시 0, 에러시 0이 아닌 에러코드

스레드 디태치

기본적으로 스레드는 조인이 가능하도록 생성된다.
하지만 조인이 가능하지 않도록 디태치하는 것도 가능하다.
부모프로세스에서 wait()를 호출하기까지 자식 프로세스가 시스템 리소스를 잡아먹는 것과 마찬가지로 스레드도 조인이 되기 전까지 시스템 리소스를 잡아먹고 있으므로 조인을 할 생각이 없는 스레드는 디태치해두어야 한다.

int pthread_detach(pthread_t thread);

return: 성공시 thread로 명시한 스레드 디태치후 0반환, 에러시 ESRCH 반환

pthread_join, pthread_detach는 한 프로세스 내의 스레드에 대해서 호출해야 그 스레드가 종료되었을 때 시스템 리소스를 해제할 수 있다.

#include <pthread.h>
#include <stdlib.h>
#include <stdio.h>

void * start_thread (void *message);

int main(void) {
        pthread_t thing1, thing2;
        const char *message1 = "Thing 1";
        const char *message2 = "Thing 2";
        
        //각각 다른 message를 받는 스레드 두개를 만든다.
        pthread_create(&thing1, NULL, start_thread, (void *) message1);
        pthread_create(&thing2, NULL, start_thread, (void *) message2);
        
        //스레드가 종료되기를 기다린다.
        //여기서 조인하지 않으면 두 스레드가 끝나기 전에 메인 스레드가 종료될 위험이 있다
        pthread_join(thing1, NULL);
        pthread_join(thing2, NULL);

        return 0;
}

void * start_thread (void *message) {
        printf("%s\n", (const char *)message);
        return message;
}

뮤텍스 함수

뮤텍스는 pthread_mutex_t 객체로 표현된다.

//뮤텍스를 선언하고 초기화한다.
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

뮤텍스 락 걸기

int pthread_mutex()_lock(pthread_mutex_t *mutex);

호출이 성공하면 mutex로 지정한 뮤텍스의 사용이 가능해질 때까지 호출한 스레드를 블록한다.
해당 뮤텍스가 사용 가능한 상태가 되면 호출한 스레드가 깨어나고 이 함수는 0을 반환한다.
호출하는 시점에 해당 뮤텍스가 사용 가능한 상태라면 이 함수는 즉시 반환된다.
에러가 발생하면 0이 아닌 에러코드 반환

뮤텍스 해제하기

int pthread_mutex_unlock(pthread_mutex_t mutex);

호출이 성공하면 mutex로 지정한 뮤텍스를 해제하고 0을 반환한다. 이 함수는 블록되지 않고 즉각 mutex를 해제한다.
에러가 발생하면 0이 아닌 에러 반환

스코프트 락
RAII(Resource Acquisition Initialization)는 리소스의 수명과 scoped 객체를 서로 엮어서 리소스 할당과 해제를 효과적으로 처리한다.
RAII는 예외가 발생한 후 리소스를 정리하기 위해 만들어졌는데
리소스를 관리하는 아주 강력한 방법이다.

0개의 댓글