스레드(Thread) 기초

bolee·2022년 4월 19일
0

소켓 응용 프로그램과 멀티스레드

https://github.com/LEEBONGHAK/TCP-IP_window_socket/tree/main/Chapter04/IPv4_server_client

위 링크에 존재하는 TCP 서버-클라이언트 예제는 다음과 같은 문제가 있다.

  1. 클라이언트 2개 이상이 서버에 접속할 수 는 있으나 서버가 동시에 접속할 수 있지만, 서버가 동시에 클라이어트 2개 이상에 서비스 할 수 없다.

    해결책과 장단점은 다음과 같다.

    1. 서버가 각 클라이언트와 연결해 통신하는 시간을 짧게 줄인다. 예를 들면, 클라이언트가 데이터를 전송하기 전에 매번 서버에 접속하고, 전송 후에는 곧바로 접속을 끊는 방식을 사용할 수 있다.
      • 장점: 쉽게 구현이 가능하며, 서버의 시스템 자원을 적게 사용한다.
      • 단점: 파일 전송 프로그램과 같이 대용량 데이터를 전송하는 응용 프로그램을 구현하는데 접합하지 않으며, 클라이언트 수가 많을 경우 처리 지연 시간이 길어질 확률이 높다.
    2. 서버에 접속한 각 클라이언트를 스레드를 이용해 독립적으로 처리한다.
      • 장점: 소켓 입출력 모델에 비해 비교적 쉽게 구현할 수 있다.
      • 단점: 접속한 클라이언트 수에 비례해 스레드를 생성하므로 서버의 시스템 자원을 많이 사용한다.
    3. 소켓 입출력 모델을 사용한다.
      • 장점: 소수의 스레드를 이용해 다수의 클라이언트를 처리할 수 있다. 따라서 2번째 방법보다 서버의 시스템 자원을 적게 사용한다.
      • 단점: 다른 두 방법보다 구현이 어렵다.
  2. 서버와 클라이언트의 send(), recv() 함수의 호출 순서가 서로 맞아야 한다. 데이터를 보내지 않은 상태에서 양쪽에서 동시에 recv() 함수를 호출하면 교착 상태가 발생할 수 있다. 여기서 교착상태(deadlock)란 영원히 일어나지 않을 사건을 두 프로세스가 기다리는 상황을 말한다.

    해결책과 장단점은 다음과 같다.

    1. 데이터 송수신 부분을 잘 설계해 교착 상태가 발생하지 않게 한다.
      • 장점: 특별한 기법을 도입하지 않고도 구현할 수 있다.
      • 단점: 데이터 송수신 패턴에 따라 교착 상태가 발생할 수 있다. 즉, 모든 경우에 적용할 수 없다.
    2. 소켓에 타임아웃(timeout) 옵션을 적용해, 소켓 함수 호출 시 작업이 완료되지 않아도 일정 시간 후 리턴하게 한다.
      • 장점: 비교적 간단하게 구현할 수 있다.
      • 단점: 다른 방법보다 성능이 떨어진다.
    3. 넌블로킹(nonblocking) 소켓을 사용한다.
      • 장점: 교착 상태를 막을 수 있다.
      • 단점: 구현이 복잡하다. 시스템 자원(특히 CPU 시간)을 불필요하게 낭비할 가능성이 크다.
    4. 소켓 입출력 모델을 사용한다.
      • 장점: 넌블로킹 소켓의 단점을 보완함과 더불어 교착 상태를 막을 수 있다.
      • 단점: 1번째나 2번째 방법보다 구현이 어렵다. 그러나 3번째 방법보다는 쉽고 일관성 있게 구현할 수 있다.

여기에서는 1번 문제점을 해결할 수 있는 방법 중 하나인 멀티스레드를 다룰 것이다.
멀티스레드를 이용하면 서버에 접속한 각 클라이언트를 독립적으로 처리할 수 있다.

스레드 기본 개념

스레드 개념을 알아보기 전에 프로세스 개념을 정리해보자.

윈도우 운영체제를 제외한 대부분의 운영체제

  • 프로그램: 저장 장치에 파일로 존재하는 정적인 개념
  • 프로세스: CPU 시간을 할당받아 실행 중인 프로그램. 코드(CPU 명령), 데이터(전역 변수, 정적 변수), 리소스(그림 파일, 사운드 파일 등)를 파일에서 읽어들여 작업을 수행하는 동적인 개념

윈도우 운영체제 (프로세스 개념을 프로세스와 스레드 두개로 구분함)

  • 프로세스(process): 코드, 데이터, 리소스를 파일에서 읽어 윈도우 운영체제가 할당해놓은 메모리 영역에 담고 있는 일종의 컨데이터(container)로, 정적인 개념
  • 스레드(thread): CPU 시간을 할당받아 프로세스 메모리 영역에 있는 코드를 수행하고 데이터를 사용하는 동적인 개념

즉, 일반 운영체제의 프로세스 = 윈도우 운영체제의 프로세스 + 스레드

윈도우 응용 프로그램이 CPU 시간을 할당받아 실행하려면 스레드가 최소 하나 이상 필요하다.
응용 프로그램 실행 시 최초로 샐성되는 스레드를 주 스레드(primary thread) 또는 메인 스레드(main thread)라고 부르는데, WinMain() 또는 main() 함수에서 실행을 시작한다.
만약 응용 프로그램에서 주 스레드와 별도로 동시에 수행하고자 하는 작업이 있다면, 스레드를 추가로 생성해 이 스레드가 해당 작업을 수행하게 하면 된다. 이를 멀티스레드 응용 프로그램(multithreaded application)이라고 한다.

CPU가 하나고 스레드는 여러개라고 가정하고 동작원리를 살펴보자.

CPU 하나가 스레드 2개를 동시에 실행할 수는 없지만 교대로 실행하는 일은 가능하다. 교대로 실행하는 간격이 충분히 짧다면 사용자는 두 스레드가 동시에 실행되는 것처럼 느낀다. 이렇게 하려면 각 스레드의 최종 실행 상태를 저장하고 나중에 복원하는 작업을 반복해야 한다. 스레드의 실행 상태란 CPU와 메모리 상태를 말하며, 구체적으로는 CPU 레지스터 값과 메모리의 스택을 의미한다.하드웨어(CPU)와 소프트웨어(운영체제)의 협동으로 이루어지는 스레드 실행 상태의 저장과 복원 작업을 컨텍스트 전환(context switch)라고 말하며 이로 인해 각 스레드는 다른 스레드의 존재와 무관하게 상태를 유지하며 실행 할 수 있다.

(6-3 그림)

위 그림은 프로세스 하나가 스레드 2개를 사용하는 원리를 보여준다.

  • (a): 스레드 1이 실행 중이다. 명령을 수행할 때마다 CPU 레지스터 값과 메모리의 스택 내용이 변경된다.
  • (b): 스레드 1의 실행을 중지하고 실행 상태를 저장한다(스택은 메모리에 계속 유지되므로 그림에서는 CPU 레지스터만 저장하는 것으로 표시함). 이전에 저장해둔 스레드 2의 상태를 복원한다.
  • (c): 스레드 2를 실행한다. 명령을 하나씩 수행할 때마다 CPU 레지스터 값고 ㅏ메모리 스택 내용이 변경된다.
  • (d): 스레드 2의 실행을 중지하고 실행 상태를 저장한다. 이전에 저장해둔 스레드 1의 상태를 복원한다.
  • (e): 스레드 1을 다시 실행한다. 이전 실행 상태를 복원했기 때문에 스레드 1은 마지막으로 수행한 명령 다음 위치부터 진행한다.

윈도우 응용 프로그램은 특성에 따라 크게 콘솔(console) 응용 프로그램과 GUI(Graphical User Interface) 응용 프로그램으로 나눌 수 있다. 두 종류 모두 멀티스레드를 이용할 수 있고 잘 활용하면 아주 유용하다. 특히 GUI 응용 프로그램은 메세지 구동 구조(message drived architecture)라는 특징 때문에 반드시 멀티 스레드를 이용해야하는 경우가 생긴다.

스레드 생성과 종료

스레드 개념을 실제 응용 프로그램에 활용하려면 윈도우 운영체제가 제공하는 함수, 즉 윈도우 API를 사용해야 한다. 스레드 생성 API 함수를 소개하기 전에 스레드 생성에 필요한 요소를 살펴보자.

(6-6 그림)

위 그림은 두 함수로 구성된 응용 프로그램(프로세스)의 주소 공간을 나타낸 것이다. 프로세스가 생성되면 main() 함수를 실행 시작점으로 하는 주 스레드가 자동으로 생성된다. 이때 또 다른 함수인 f()를 실행 시작점으로 하는 스레드를 생성하려면 다음과 같은 정보를 운영체제에 제공해야 한다.

  • f() 함수의 시작 주소
    운영체제는 f() 함수의 시작 주소를 알아야 한다. C/C++ 에서는 함수 이름이 곧 그 함수의 시작 주소를 의미한다. f() 함수와 같이 스레드 실행 시작점이 되는 함수를 스레드 함수(thread function)이라고 부른다.
  • f() 함수 실행 시 사용할 스택의 크기
    C/C++ 의 모든 함수는 실행 중 인자 전달과 변수 할당을 위해 스택이 필요하다. 만약 f() 함수를 실행 시작점으로 하는 스레드 2개를 생성하고자 한다면, 서로 다른 메모리 위치에 스택 2개를 할당해야 한다. 스레드 실행에 필요한 스택 생성은 운영체제가 자동으로 해주기 때문에 응용 프로그램은 스택 크기만 알려주면 된다.

CreateThread()

CreateThread() API 함수는 윈도우에서 스레드를 생성할 때 사용하며, 스레드를 생성한 후 스레드 핸들(thread handle)을 리턴한다.

스레드 핸들은 파일 디스크립터나 소켓 디스크립터와 비슷한 개념으로, 운영체제의 스레드 관련 데이터 구조체를 간접적으로 참조하는 매개체 역할을 한다. 응용 프로그램은 스레드 핸들을 윈도우 API 함수에 전달함으로써 다양한 방식으로 스레드를 제어할 수 있다.

HANDLE CreateThread(
	LPSECURITY_ATTRIBUTES	lpThreadAttributes,
    SIZE_T					dwStackSize,
    LPTHREAD_START_ROUTINE	lpStartAddress,
    LPVOID					lpParameter,
    DWORD					dwCreationFlags,
    LPDWORD					lpThreadId
);
  • lpThreadAttributes: SECURITY_ATTRIBUTES 구조체를 통해 핸들 상속(handle inheritance)과 보안 디스크립터(security descriptor) 정보를 전달한다. NULL 값을 사용해도 스레드를 생성하고 활용하는데 무방하다.
  • dwStackSize: 스레드에 할당되는 스택 크기(바이트 단위)이다. 0을 사용하면 실행 파일의 헤더에 들어 있는 기본 크기를 사용해는데, 비주얼 C++로 응용 프로그램을 만든 경우 기본 크기는 1MB이다.
  • lpStartAddress: 스레드 함수의 시작 주소이다. 스레드 함수는 반드시 다음과 같은 형태로 정의해야 한다.
    DWORD WINAPI ThreadProc(LPVOID lpParameter)
    {
    	...
    }
  • lpParameter: 스레드 함수에 전달할 인자다. void 포인터이므로 포인터 크기보다 같거다 작은 데이터는 값 또는 주소 형태로 전달하면 된다. 포인터 크기보다 큰 데이터는 값을 구조체나 배열에 넣고 주소 형태로 전달하면 된다. 전달할 인자가 없으면 NULL 값을 사용하면 된다.
  • dwCreationFlags: 스레드 생성을 제어하는 값으로 0 또는 CREATE_SUSPENDED를 사용한다. 0을 사용하면 스레드는 생성 후 곧바로 실해되고, CREATE_SUSPENDED를 사용하면 스레드가 생성은 되지만 ResumeThread() 함수를 호출하기 전까지 실행되지 않는다.
  • lpThreadId: DWORD형 변수를 전달하면 여기에 스레드 ID가 저장된다. 스레드 ID가 필요 없으면 윈도우 NT 계열에서는 NULL 값을 사용해도 된다.

ExitThread(), TerminateThread()

윈도우에서 스레드를 종료하는 방법에는 아래 4가지가 있다.
1. 스레드 함수가 리턴한다.
2. 스레드 함수 안에서 ExitThread() 함수를 호출한다.
3. 다른 스레드가 TerminateThread() 함수를 호출해 스레드를 강제 종료 시킨다.
4. 주 스레드가 종료하면 모든 스레드가 종료된다.

일반적으로 1, 2번 방법으로 스레드를 종료하는 것이 바람직하다. 3번은 꼭 필요한 경우에만 사용해야 하며, 4번의 경우는 정상적인 방법이라기보다 주 스레드(즉, 메인 함수)의 특성으로 이해하면 된다.

[스레드 함수 호출 규약]
스레드 함수에 사용하는 WINAPI 키워드는 호출 규약을 의미한다. 호출 규약(calling convertion)이란 컴파일러가 코드를 생성하는 방법을 나타내는 것으로, 함수를 호출하는 쪽과 호출되는 함수쪽에서 일관성 있게 따라야 하는 약속이다.
WinDef.h 헤더 파일을 찾아보면 다음과 같은 정의문을 볼 수 있다.

#define CALLBACK	__stdcall
#define WINAPI		__stdcall
#define APIENTRY	WINAPI
#define APIPRIVATE	__stdcall
#define PASCAL		__stdcall

stdcall은 대부분 윈도우 API에서 사용하는 호출 규약이다.
윈도우 API 함수는 응용 프로그램이 호출하는 반면, 스레드 함수는 운영체제가 호출한다. 운영체제가
stdcall 호출 규약에 따라 스레드 함수를 호출하므로 응용 프로그램의 스레드 함수는 같은 호출 규약을 따라야 한다. 이러한 약속을 강제하는 것이 스레드 함수에 붙이는 WINAPI 키워드며, 이 키워드를 제거하면 컴파일러는 오류를 발생시킨다.

void ExitThread(
	DWORD dwExitCode	// 종료 코드
);
// 성공: 0이 아닌 값, 실패: 0
BOOL TerminateThread(
	HANDLE hThread,		// 종료할 스레드를 가리키는 핸들
    DWORD dwExitCode	// 종료 코드
);

스레드 생성과 종료 예제

위에 내용을 토대로 스레드 생성과 종료를 보여주는 간단한 코드를 살펴보면 아래와 같다.
해당 코드는 스래드 함수 하나를 실행하는 스레드를 2개 생성하되, 스레드 함수 인자를 통해 값을 전달하는 응용 프로그램이다.

001	#include <windows.h>
002	#include <stdio.h>
003
004	struct Point3D
005	{
006		int x, y, z;
007	};
008
009	DWORD WINAPI MyThread(LPVOID arg)
010	{
011		Point3D *pt = (Point3D *) arg;
012	    while (1)
013	    {
014	    	printf("Running MyThread() %d : %d, %d, %d\n", \
015	        	GetCurrentThreadId(), pt->x, pt->y, pt->z);
016	        Sleep(1000);
017	    }
018	    return 0;
019	}
020
021	int main(int argc, char **argv)
022	{
023		// 첫 번째 스레드 생성
024	    Point3D pt1 = {10, 20, 30};
025	    HANDLE hThread1 = CreateThread(NULL, 0, MyThread, &pt1, 0, NULL);
026	    if (hThread == NULL)
027	    	return 1;
028		CloseHandle(hThread1);
029		
030		// 두 번째 스레드 생성
031		Point3D pt2 = {40, 50, 60};
032		HANDLE hThread2 = CreateThread(NULL, 0, MyThread, &pt2, NULL);
033		if (hThread2 == NULL)
034			return 1;
035		CloseHandle(hThread2);
036
037		while (1)
038		{
039			printf("Running main() %d\n", GetCurrentThreadId());
040			Sleep(1000);
041		}
042		
043		return 0;
044	}
  • 04-07: 스레드 함수에 32비트보다 큰 값을 전달하기 위한 구조체 정의
  • 11: void 포인터를 Point3D 포인터로 형변환하여 구조체 멤버 x, y, z에 접근한다.
  • 12-17: 무한 루프를 돌면서 1초마다 스레드ID와 x, y, z를 출력한다.
  • 18: 스레드 함수가 리턴함으로써 스레드가 종료한다. 무한 루프를 돌기 때문에 실제로 이 코드는 수행되지 않는다.
  • 24-28, 31-35: 스레드 2개를 생성하되, 오류가 발생하면 1(실패)를 리턴하면서 응용 프로그램을 종료한다. CreateThread() 함수가 리턴하는 핸들은 나중에 스레드를 조작할 필요가 없으면 가급적 빨리 닫는 것이 좋다. CloseHandle() 함수를 호출해 핸들을 닫는다고해서 스레드가 종료되는 것은 아니라는 점에 유의하자
  • 37-41: 주 스레드는 무한 루프를 돌면서 1초마다 스레드 ID를 출력한다. 이 부분을 제거하면 주 스레드가 0을 리턴하면서 응용 프로그램, 즉 프로세스가 종료한다. 프로세스가 종료하면 프로세스 내부에서 실행 중인 모든 스레드는 자동으로 종료한다.

실행결과는 아래와 같다.

이 때 다른 프로그램 몇 개를 실행하고 돌아오면 실행 패턴이 바뀔 수 있다. 한 프로세스 내 스레드 실행 순서는 시스템에 존재하는 다른 스레드의 영향을 받으므로 이와 같은 변화가 생긴다.

[_beginthreadex(), _endthreadex() 활용하기]
C/C++ 라이브러리 함수를 사용하는 응용 프로그램에서는 CreateThread(), ExitThread() API 함수보다 _beginthreadex(), _endthreadex() 라이브러리 함수를 사용하는 것이 바람직하다. 두 함수의 원형은 다음과 같다.

#include <process.h>
uinptr_t _beginthreadex(
	void *security,
    unsigned stack_size,
    unsigned (__stdcall *start_address)(void *),
    void *arglist,
    unsigned initflag,
    unsigned *thrdaddr
);
#include <process.h>
void _endthreadex(
	unsigned retval
);

_beginthreadex(), _endthreadex() 함수는 각각 CreateThread(), EndThread() 함수와 같은 역할을 하며, 실제로 내부적으로 이들 API를 호출한다. 차이가 있다면 C/C++ 라이브러리가 멀티스레드 환경에서 문제없이 동작하도록 부가적인 작업을 한다는 점이다. API 함수와 인자의 타입만 약간 다를 뿐 순서와 의미가 같아 기존 코드를 거의 변경할 필요없이 사용할 수 있다.

스레드 제어

스레드는 윈도우 운영체제의 실행 단위므로, 우선순위를 변경하거나 실행을 중지하고 재시작하는 등의 제어 기능을 윈도우 API 수준에서 지원한다. 기능별로 스레드 제어 API 함수를 살펴보자.

스레드 우선순위 변경

윈도우 운영체제에서 항상 여러 스레드가 CPU 시간을 사용하려고 경쟁한다. 따라서 각 스레드에 CPU 시간을 적절히 분배하기 위한 정책을 사용하는데, 이를 스레드 스케줄링(thread scheduling) 또는 CPU 스케줄링(CPU scheduling)이라 부른다.

윈도우 운영체제의 스케줄링 기법은 우선순위(priority)에 기반한 것으로, 우선순위가 높은 스레드에 우선적으로 CPU 시간을 할당한다. 스레드의 우선순위를 결정하는 요소는 다음과 같다.

  • 프로세스 우선순위: 우선순위 클래스(priority class)라 부른다.
  • 스레드 우선순위: 우선순위 레벨(priority level)이라 부른다.

우선순위 클래스는 프로세스 속성으로, 한 프로세스가 생성한 스레드는 우선순위 클래스가 모두 같다는 특징이 있다. 윈도우 운영체제에서 제공하는 우선순위 클래스는 다음과 같다.

  • REALTIME_PRIORITY_CLASS (실시간)
  • HIGH_PRIORITY (높음)
  • ABOVE_NORMAL_PRIORITY_CLASS (높은 우선순위; 윈도우 2000 이상)
  • NORMAL_PRIORITY_CLASS (보통)
  • BELOW_NORMAL_PRIORITY_CLASS (낮은 우선순위; 윈도우 2000 이상)
  • IDLE_PRIORITY_CALSS (낮음)

우선순위 레벨은 스레드 속성으로, 같은 프로세스에 속한 스레드 간 상대적인 우선순위를 결정할 때 사용한다. 윈도우 운영체제에서 제공하는 우선순위 레벨은 다음과 같다.

  • THREAD_PRIORITY_TIME_CRITICAL
  • THREAD_PRIORITY_HIGHEST
  • THREAD_PRIORITY_ABOVE_NORMAL
  • THREAD_PRIORITY_NORMAL
  • THREAD_PRIORITY_BELOW_NORMAL
  • THREAD_PRIORITY_LOWEST
  • THREAD_PRIORITY_IDLE

우선순위 클래스와 우선순위 레벨을 결합하면 스레드의 기본 우선순위(base priority)가 결정되고, 이 값이 스레드 스케줄링에 사용된다. 윈도우의 스케줄링 방식에서는 우선순위가 가장 높은 스레드에 CPU 시간을 할당하되, 우선순위가 같은 스레드가 여러 개 있을 때는 CPU 시간을 번갈아가며 할당한다.

(6-10 그림)

우선순위가 높은 스레드가 계속 CPU 시간을 요구하면 우선순위가 낮은 스레드는 CPU 시간을 전혀 할당받지 못하는 문제가 생기는데, 이를 기아(starvation)이라고 한다. 이를 해겨리하려고 윈도우 운영체제는 오랜 시간 CPU 시간을 할당받지 못한 스레드의 우선순위를 단계적으로 끌어올려서 우선순위가 낮은 스레드도 CPU를 사용할 수 있게 한다. 또한 현제 사용자가 작업하고 있는 프로그램의 반응 속도를 빠르게 하려고 우선순위를 동적으로 변경하기도 한다.

멀티스레드를 이ㅛㅇ할 때 작업의 중요도에 따라 응용 프로그램이 직접 우선순위를 변경하기도 한다. 이때 우선순위 클래스를 변경하는 경우는 흔치 않으며, 대개 우선순위 레벨을 변경한다.
우선순위 레벨 관련 API 함수는 다음과 같다. SetThreadPriority() 함수는 우선순위 레벨을 변경할 때, GetThreadPriority() 함수는 우선순위 레벨을 얻을 때 사용한다.

// 성공: 0이 아닌 값, 실패: 0
BOOL SetThreadPriority(
	HANDLE hThread,	// 스레드 핸들
    int nPriority	// 우선순위 레벨
);

// 성공: 우선순위 레벨, 실패: THREAD_PRIORITY_ERROR_RETURN
int GetThreadPriority(
	HANDLE hThread	// 스레드 핸들
);

스레드 우선순위 변경 예제

주 스레드 외에 새로운 스레드를 CPU 개수만큼 생성하고 우선순위 레벨을 변경하는 응용 프로그램 예제이다.

#include <windows.h>
#include <stdio.h>

DWORD WINAPI MyThread(LPVOID arg)
{
	while (1);
    return 0;
}

int main()
{
	// CPU 개수를 알아낸다.
    SYSTEM_INFO si;
    GetSystemInfo(&si);
    
    // CPU 개수만큼 스레드 생성
    for (int i = 0; i < (int) si.dwNumberOfProcessors; i++)
    {
    	HANDLE hThread = CreateThread(NULL, 0, MyThread, NULL, 0, NULL);
        if (hThread == NULL)
        	return 1;
        // 최고 우선순위로 변경한다.
        SetThreadPriority(hThread, THREAD_PRIORITY_TIME_CRITICAL);
        CloseHandle(hThread);
    }
    
    Sleep(1000);
    while (1)
    {
    	printf("주 스레드 실행!\n");
        break;
    }
    return 0;
}

실행 결과는 다음과 같다. CPU 개수만큼 생성한 MyThread 스레드의 우선순위 레벨은 main 스레드 보다 높게 설정하고 무한 루프를 돌면서 CPU 시간을 계속 요구하므로, 우선 순위가 고정되어 있다면 MyThread 스레드만 CPU 시간을 할당받게 된다. 이때 기아(stravation)가 발생할 수 있으나, 윈도우 운영체제에서는 오랜 시간 CPU 시간을 할당 받지 못한 스레드의 우선순위를 단계적으로 끌어올리기 때문에 결과 같이 main 스레드도 실행할 기회를 얻는다.

스레드 종료 기다리기

스레드는 일단 CPU 시간을 사용하려고 다른 스레드와 경쟁하며 독립적으로 실행되지만 때로 한 스레드가 다른 스레드의 종료 여부, 즉 작업 완료 여부를 확인해야할 때가 생긴다.
WaitForSingleObject() 함수를 사용하면 특정 스레드가 종료할 때까지 기다릴 수 있다.

// 성공: WAIT_OBJECT_0 또는 WAIT_TIMEOUT, 실패: WAIT_FAILED
DWORD WaitForObject(
	HANDLE	hHandle,
    DWORD	dwMilliseconds
);
  • hHandle: 종료를 기다릴 대상 스레드
  • dwMilliseconds: 대기 시간으로, 밀리초 단위를 사용한다. 이 시간안에 스레드가 종료하지 않으면 WaitForSingleObject() 함수는 리턴하고, 이때 리턴값은 WAIT_TIMEOUT이 된다. 스레드가 종료한 경우 WAIT_OBJECT_0을 리턴한다. 대기 시간으로 INFINITE 값을 사용하면 스레드가 종료할 때까지 무한히 기다린다.

여러 스레드가 종료하기를 기다리려면 WaitForSingleObject() 함수를 스레드 개수만큼 호출해야 하는데, 대신 WaitForMultipleObjects() 함수를 사용하면 호출 한 번으로 끝낼 수 있다.

// 성공: WAIT_OBJECT_0 ~ WAIT_OBJECT_0 + nCount - 1 또는 WAIT_TIMEOUT, 실패: WAIT_FAILED
DWORD WaitForMultipleObjects(
	DWORD			nCount,
    count HANDLE	*lpHandles,
    BOOL			bWaitAll,
    DWORD			dwMilliseconds
);
  • nCount, lpHandles: WaitForMultipleObjects() 함수를 사용할 때는 스레드 핸들을 배열에 넣어서 전달해야 한다. nCount는 배열 원소개수, lpHandles는 배열의 시작 주소를 나타낸다. nCount의 최댓값은 MAXIMUM_WAIT_OBJECTS(=64)로 정의되어 있다.
  • bWaitAll: TRUE면 모든 스레드가 종료할 때까지 기다린다. FALSE면 한 스레드가 종료하는 즉시 리턴한다.
  • dwMilliseconds: 사용법과 의미는 WaitForSingleObject()함수의 두 번째 인자와 같다.

참고로 WaitForSingleObject()WaitForMultipleObjects()함수는 스레드 종료를 기다리는 전용 함수가 아니*라는 점에 주의하자. 두 함수는 모두 스레드 동기화(thread synchronization)에 사용하는 범용 함수로, 여기서는 단지 스레드 종료를 기다리는 목적으로 이용한 것이다.

스레드 실행 중지와 재시작

스레드 핸들을 보유하고 있으면 SuspendThread() 함수를 호출해 해당 스레드 실행을 일시 중지하거나 ResumeThread() 함수를 호출해 재시작할 수 있다. 윈도우 운영체제는 스레드의 중지 횟수(suspend count)를 관리하는데, 이 값은 SuspendThread()함수를 호출할 때마다 1씩 증가하고 ResumeThread()함수를 호출할 때마다 1씩 감소한다. 중지 횟수가 0보다 크면 스레드는 실행 중지 상태에 있게 한다. 따라서 한 스레드에 대해 SuspendThread() 함수를 두 번 호출했다면 ResumeThread()함수를 두 번 호출해야 재시작할 수 있다.

// 성공: 중시 횟수, 실패: -1
DWORD SuspendThread(
	HANDLE	hThread	// 스레드 핸들
);

// 성공: 중지 횟수, 실패: -1
DWORD ResumeThread(
	HANDLE	hThread	// 스레드 핸들
);

비슷한 기능을 제공하는 것으로 Sleep()함수가 있다. SyspendThread()함수를 호출한 경우에는 반드시 ResumeThread()함수를 사용해야 스레드가 재시작하지만, Sleep()함수를 호출하면 dwMilliseconds로 지정한 시간이 지나면 자동으로 재시작한다는 차이가 있다.

void Sleep(
	DWORD	dwMilliseconds	// 밀리초(ms)
);

Sleep(0)를 호출하면 스레드는 자신에게 할당된 CPU 시간을 포기하고 남은 시간을 우선순위가 같은 다른 스레드에 넘겨준다. 이를 이용하면 스레드 간 컨텍스트 전환을 빠르게 할 수 있다.

스레드 실행 제어와 종료 기다리기 예제

#include <windows.h>
#include <stdio.h>

int sum = 0;

DWORD WINAPI MyThread(LPVOID arg)
{
	int num = (int) arg;
    for (int i = 1; i <= num; i++)
    	sum += i;
    return 0;
}

int main(int argc, char **argv)
{
	int num = 100;
    HANDLE hThread = CreateThread(NULL, 0, MyThread, (LPVOID) num, CREATE_SUSPENDED, NULL);
    if (hThread == NULL)
    	return 1;
        
    printf("스레드 실행 전, 계산 결과 = %d\n", sum);
  	ResumeThread(hThread);
    WaitForSingleObject(hThread, INFINITE);
    printf("스레드 실행 후, 계산 결과 = %d\n", sum);
    CloseHandle(hThread);
    
    return 0;
}

실행 결과는 다음과 같다.

참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018

1개의 댓글

comment-user-thumbnail
2022년 11월 1일

안녕하세요 글 작성하신게 제가 고민하는거랑 비슷하다고 생각해서 염치불구하고 댓글을 남깁니다.
[외부]client -> server -> Client1 -> [외부] server[port1]
-> Client2 -> [외부] server[port2]
이런식으로 구성하려고 하는데 처음에 한 것은 직접적으로 조건에 맞는 패킷이 들어올때마다 클라이언트를 실행시키고 끄고 했는데 서버연결한 다음 클라이언트 연결하고 조건에 맞는 패킷이 들어왔을때마다 내보내겠금하려고 합니다. 그런데 Client1에는 연결이 되는데 client2는 연결이 안됩니다. 하나만 되요.. 함수 한 개를 이용하여 두 포트를 동시에 실행시키려 스레드를 사용했는데도 똑같습니다. ㅠㅠ 도움주실 수 있나요?

답글 달기