신규 블로그를 만들었습니다!

2020년 이후부터는 아래 블로그에서 활동합니다.

댓글로 질문 주셔도 확인하기 어려울 수 있습니다.

>> https://bluemiv.tistory.com/

Chapter 06. 스레드의 기본

모든 프로세스는 하나 이상의 스레드를 사용한다.

프로세스는 2개의 요소(프로세스 커널 오브젝트주소공간)로 구성되어있다. 마찬가지로 스레드 또한 유사하게 2개의 요소로 구성되어있다.

스레드의 구성요소 2가지
  • 스레드 커널 오브젝트: 운영체제가 스레드(Thread)를 다루기 위해 사용하는 오브젝트. 또한, 시스템이 스레드에 대한 통계 정보를 저장하는 공간.

  • 스레드 스택: 스레드 코드를 수행할 때, 함수의 매개변수와 지역변수를 저장하기 위한 스택(Stack).

프로세스는 스스로 수행될 수 없다. 단순히 생각하면 프로세스는 스레드의 저장소라고 볼 수 있다. 스레드는 항상 프로세스 컨텍스트(Context) 내에 생성되며, 프로세스 안에서만 살아 있을 수 있다.

정리하면, 스레드는 프로세스 주소 공간 내에 있는 코드를 수행하고 데이터를 다룬다. 그래서, 스레드가 2개 이상인 경우, 스레드들은 단일 주소공간을 공유하게 된다. 스레들은 동일한 코드를 수행하고 동일한 데이터를 다룰 수 있다.

커널 오브젝트 핸들 테이블이 스레드 별로 존재하는 것이 아니라 프로세스 별로 존재하기 때문에, 역시나 스레드 커널 오브젝트 핸들도 공유하게 된다.

프로세스
  • 프로세스 별로 가상 주소공간을 생성하는 것은 매우 많은 시스템 리소스를 필요로 한다.
  • .exe 또는 .dll 파일이 주소 공간으로 로드 되어야 하므로 파일 리소스 또한 필요로 한다.
  • 즉, 메모리를 많이 필요로 한다.
스레드
  • 프로세스에 비해 상당히 적은 리소스를 필요로 한다.
  • 스레드는 단지 스레드 커널 오브젝트와 스레드 스택만을 필요로 한다.
  • 시스템 내부에 저장해 두어야 하는 내용이 비교적 적어 메모리도 덜 차지한다.

스레드는 프로세스에 비해 비교적 시스템 부하가 적기 때문에, 프로세스 대신 스레드를 이용하여 문제를 해결하는 것이 좋다. 하지만, 반드시 그런 것은 아니고 프로세스를 이용할 때 더욱 좋은 설계 방향이 되는 경우도 있다.

스레드와 프로세스 둘 사이에는 상충관계(trade off)가 있다.

Section 01. 스레드를 생성해야 하는 경우

스레드는 프로세스 내의 수행 흐름을 의미한다. 프로세스의 초기화가 이루어지는 동안 시스템은 주 스레드를 생성한다.

마이크로소프트 C/C++ 컴파일러로 작성된 경우, 주 스레드는 C/C++ 런타임 라이브러리의 시작 코드를 수행하는 것으로 시작된다. 이후 진입점 함수를 호출하고 반환될 때까지 수행한다. 반환이되면 C/C++ 런타임 라이브러리의 시작코드가 ExitProcess 를 호출하여 종료된다.

주 스레드의 흐름
  • C/C++ 런타임 라이브러리의 시작 코드를 수행
  • 진입점 함수 호출
  • 진입점 함수 반환
  • C/C++ 런타임 라이브러리의 시작 코드가 ExitProcess 를 호출

멀티스레드를 이용하면 작업을 분리된 스레드로 수행하여, 작업이 진행중인 상황에서도 계속해서 다른 작업을 수행할 수 있다.

또한, 버그로 인해 해당 스레드가 무한루프에 빠지더라도, 다른 프로세스들을 계속해서 사용할 수 있다. 이렇게 멀티 스레드를 이용하면 확장성이 좋은 구조로 애플리케이션을 설계 할 수 있다.

윈도우 탐색기의 경우는 특정 스레드가 폴더를 핸드링하다 죽더라도, 다른 폴더는 여전히 정상 동작한다. 이렇게 스레드를 활요할 수 있다.

Section 02. 스레드를 생성하지 말아야 하는 경우

멀티스레드를 이용하면 매우 장점이 많을 것 같지만, 도움이 되지 않는 경우도 있다. (무조건 좋은 것이 아니다.)

새로운 문제를 야기시키기도 하는데, 예를들어 워드 프로세서 애플리케이션을 개발한다고 할때, 프린트 기능을 독립된 스레드로 구현한다고 하자.

편집하는 동안 프린트를 할 수 있다는 장점이 있다. 하지만, 이런 방식은 출력하는 동안 문서가 바뀔 수 있다는 것을 의미한다.

그래서, 이런 경우는 보통 복사본을 만들어서 복사본을 프리트하고, 완료된 후 삭제하는 방식으로 한다.

Section 03. 처음으로 작성하는 스레드 함수

모든 스레드는 수행을 시작할 진입점 함수(entry-point function)를 반드시 가져야 한다.

진입점 함수 종류
  • main
  • wmain
  • WinMain
  • wWinMain

프로세스 내에 두번째 스레드를 만들려면 새로 생성되는 스레드는 아래와 같은 형태의 진입점 함수를 반드시 가져야 한다.

DWORD WINAPI ThreadFunc(PVOID pvParam) {
    DWORD dwResult = 0;
    ...
    return (dwResult);
}

스레드 함수는 수행한 뒤, 언젠가는 끝나고 반환될 것이다. 스레드 함수가 반환되는 시점에 스레드는 수행을 멈추고 스레드가 사용하던 스택도 반환한다. 또한 스레드 커널 오브젝트의 사용 카운트도 감소한다. (사용 카운트가 0이 되면 스레드 커널 오브젝트는 파괴됨)

스레드 함수에 대한 몇 가지 중요한 점
  • 애플리케이션에 여러 개의 스레드 함수가 필요하다면 각각은 서로 다른 이름으로 명명되어야 한다.
  • 스레드 함수는 반드시 값을 반환해야 한다. 나중에 이 값은 스레드의 종료코드가 된다.
  • 만일 정적변수(static)나 전역변수(global)를 사용하게 되면 다수의 스레드가 동시에 변수에 접근 할 수 있다. 이는 변수의 값이 잘못 변경되는 원인이 된다. 하지만, 함수의 매개변수와 지역변수는 스레드의 스택에 유지되기 때문에, 다른 스레드에 의해 내용이 변경될 가능성은 거의 없다.

Section 04. CreateThread 함수

두 번째 스레드를 생서하고 싶다면 이미 수행중인 스레드 내에서 CreateThread를 호출하면 된다.

HANDLE CreateThread(
    PSECURITY_ATTRIBUTES psa, // SECURITY_ATTRIBUTES를 가리키는 포인터
    DWORD cbStackSize, // 스택 주소 공간 사이즈
    PTHREAD_START_ROUTINE pfnStartAddr, // Thread 시작 주소
    PVOID pvParam, // 단순히 새로운 스레드로 전달한 매개변수
    DWORD dwCreateFlags, // 세부적인 제어를 수행하기 위한 플래그 값
    PDWORD pdwThreadID // 새로우 스레드에 할당되는 스레드 ID 값
); 

CreateThread 가 호출되면 시스템은 스레드 커널 오브젝트를 생성한다. (스레드 커널 오브젝트는 운영체제가 스레드를 다루기 위한 조그만 데이터 구조체에 불과)

다음에는 시스템은 스레드가 사용할 스택을 확보한다. 새로운 스레드는 스레드를 생성한 프로세스와 동일한 컨텍스트 내에서 수행되기 때문에, 모든 커널 오브젝트, 메모리, 다른 스레드의 스택에 조차 접근이 가능하다.

CreateThread는 윈도우가 제공하는 스레드 생성 함수이다. C/C++로 작성하는 경우, 마이크로소프트 C/C++ 런타임 라이브러리에서 제공하는 _begin-threadex 함수를 사용해야한다.

psa

psa 매개변수는 SECURITY_ATTRIBUTES 구조체를 가리키는 포인터다. 만약 기본 보안 특성을 이용하는 경우 NULL 을 전달하면 된다.

cbStackSize

cbStackSize 매개변수에는 스레드가 자신의 스택에 얼마 만큼의 주소공간을 사용할지를 지정한다.

실행 파일 내에 저장되어 있는 스택의 크기를 변경하기 위해서는 링커의 /STACK 스위치를 사용하면 된다.

/STACK:[ reserve] [, commit]
  • reserve: 시스템이 스레드 스택을 위해 지정된 크기 만큼의 주소 공간을 예약함. (기본값은 1MB)
  • commit: 스택으로 예약된 주소 공간에 커밋된 물리적 저장소의 초기 크기를 뜻함.

스레드가 코드를 수행함에 따라 한 페이지 이상의 스택을 필요로 할 수도 있을 텐데, 이 경우 스레드의 스택 오버플로 예외(overflow exception)를 발생시킨다.

예외가 발생하면 추가적인 페이지를 예약된 주소 공간상에 커밋해준다. 이러한 방식으로 스레드가 사용하는 스택은 필요 시 동적으로 커지게 된다.

만약, cbStackSize 매개변수로 0을 전달하면, CreateThread/STACK 링커 스위티를 이용하여 실행 파일내에 포함된 커밋된 물리적 저장소의 초기 크기를 따르게 된다.

재귀호출로 자신을 계속 호출하는 경우 스택은 점차 커져서 메모리 전체를 사용할 수도 있다. 그렇기 때문에 스택 크기를 제한함으로써 애플리케이션이 물리적 저장소의 대부분을 사용하는 것을 막을 수 있다. (프로그램 내의 버그를 더 빨리 캐치할 수 있음)

pfnStartAddr과 pvParam

pfnStartAddr는 스레드 함수의 주소를 가리킨다.

pvParamCreateThread 함수의 pvParam 매개변수로 전달한 값이 그대로 전달된다. 전달하는 용도 이외의 다른 용도로는 사용하지 않는다.

동일한 스레드 함수를 사용할 때, 각 스레드를 구분하기 위해 pvParam 매개변수를 통해 서로 다른 값을 전달 할 수 있다.

DWORD WINAPI FirstThread(PVOID pvParam) {
    // 스택 기반 변수를 초기화한다.
    int x = 0;
    DWORD dwThradID;

    // 새로운 스레드를 생성한다.
    HANDLE hThread = CreateThread(NULL, 0, SecondThread, (PVOID) &x, 0, &dwThreadID);

    // 스레드가 작업을 완료하였다.
    // 버그: 이 스레드 함수가 반환되면 스레드 스택은 파괴될 것이다.
    //        하지만 SecondThread는 여전히 이 스레드 함수의 스택에
    //        접근을 시도할 수 있다.
    return(0);
}

DWORD WINAPI SecondThread(PVOID pvParam) {
    // 일반적인 작업을 수해

    // FirstThread 함수의 스택에 존재하는 변수에 접근하려 시도한다.
    // 주의: 언제 아래 문장이 수해되느냐에 따라
    //        접근 위반을 유발할 가능성이 있다.
    * ((int *) pvParam) = 5;
    ...
    return(0);
}

SeondThreadFirstThread가 이미 종료되었다는 사실을 알지 못하기 때문에 유효하지 않은 x 변수에 접근하려 할 것이다. 해결책으로는 x 를 정적 변수로 선언하는 것이다.

하지만, 변수를 정적으로 선언하게 되면 이 함수는 재진입이 불가능한(nonreentrant) 함수가 된다. 2개의 스레드가 동일한 스레드 함수를 동시에 수행하는 것이 불가능해진다는 의미이다.

dwCreateFlags

스레드를 생성할 때 세부적인 제어를 수행하기 위한 추가적인 플래그를 지정하는데 사용된다. 0을 전달하면 스레드 생성 즉시 CPU에 의해 스케줄 가능하게 된다.

만약 CREATE_SUSPENDED 를 전달하면 시스템은 스레드를 바로 CPU에 의해 스케줄 되지 않도록 "일시정지" 상태를 유지하게 된다.

pdwThreadID

새로 생성되는 스레드의 ID 값을 저장할 DWORD 변수를 가리키는 주소를 지정하면 된다.

Section 05. 스레드의 종료

스레드는 4가지 방법으로 종료될 수 있다.

  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기