본문 바로가기

C

스레드2

반응형

reference. 캄란 아미니 - 전문가를 위한 C

 

[스레드]

- 모든 스레드는 프로세스에 의해 시작되며, 프로세스에서 벗어날 수 없다. 스레드를 공유하거나 스레드의 소유권을 다른 프로세스에 넘길 수 없다는 뜻이다. 모든 프로세스는 최소한 하나의 스레드를 가지며 이를 메인 스레드(main thread)라고 한다. C 프로그램에서 main 함수는 메인 스레드에 속해서 실행된다.

 

모든 스레드는 같은 프로세스ID (PID) 를 공유한다. top 또는 htop 과 같은 유틸리니를 사용할 때, 스레드들이 같은 프로세스 ID를 공유하며 해당 ID 및으로 스레드가 모인다는 것은 쉽게 확인할 수 있다. 그 뿐만 아니라 소유자 프로세스의 모든 속성은 스레드가 상속받을 수 있는데, 이러한 속성에는 그룹 ID, 사용자 ID, 현재 작업 경로, 신호 핸들러 등이 있다.

(예를 들면 스레드의 현재 작업 경로는 소유자 프로세스와 같다)

 

모든 스레드는 고유의 지정된 스레드 ID (TID) 가 있다. 이 ID 는 해당 스레드로 신호를 전달하거나 디버깅할 때, 스레드를 추적하기 위해 사용된다. 스레드 ID 는 POSIX 스레드 내부에서 볼 수 있으며 pthread_t 변수를 통해 접근할 수 있다. 게다가 모든 스레드는 지정된 시그널 마스크(signal mask)가 있어 스레드가 받을 신호를 필터링할 때 사용할 수 있다.

 

같은 프로세스 안에 있는 모든 스레드는 해당 프로세스의 스레드가 연 모든 파일 서술자(FD, file descriptor)에 접근할 수 있다. 따라서 모든 스레드는 이러한 파일 서술자가 가리키는 자원을 읽거나 저장할 수 있다. 소켓 서술자(socket descriptor)열린 소켓(socket) 에 대해서도 마찬가진로 읽고 쓸 수 있다.

 

스레드는 프로세스가 상태를 공유하거나 전송할 때 사용한 모든 기법을 사용할 수 있다. 참고로 DB 같은 공유된 곳에서 공유상태가 있는 경우는 네트워크에서 이를 전송하는 경우와 다르다는 점을 유의해야 한다. 이들은 서로 다른 두 가지 IPC 기법에 해당한다.

 

POSIX 호환 시스템애서 상태를 공유 및 전송 할 때 스레드가 사용한 방법

 

  • 소유자 프로세스의 메모리(데이터, 스택, 힙 세그먼트)
  • (이 방법은 프로세스가 아닌 오직 스레드에만 한전된다)
  • 파일 시스템
  • 메모리 맵 파일
  • 네트워트(인터넷 소켓을 사용한)
  • 스레드 간 신호 전달
  • 공유 메모리
  • POSIX 파이프
  • UNIX 도메인 소켓
  • POSIX 메시지 대기열
  • 환경변수

 

각 전송 방법의 간단 설명 -> POSIX 동시성 (tistory.com)

 

같은 프로세스 내의 모든 프세드는 공유 상태를 저장하고 관리하기 위해 같은 프로세스의 메모리를 사용한다. 이는 여러 스레드 간에 상태를 공유하는 가장 일반적인 방식이다. 그러려면 보통 프로스세의 힙 세그먼트를 사용한다.

 

스레드의 생명 주기는 소유자 프로세스의 생명 주기와 같다. 프로세스가 킬 되거나 종료되면, 해당 프로세스에 속하는 모든 스레드도 종료된다.

또한 메인 스레드가 종료되면 프로세스는 즉시 종료된다.

(조금 더 들여다 본다면 메인 스레드가 종료되어도 필연적으로 프로세스가 종료되는 것은 아니다. 메인 스레드가 종료된 후에도 프로세스는 다른 활성 스레드가 있을 경우 계속 실행될 수 있으며, 메인 스레드의 종료가 프로세스의 종료를 의미하는 것은 메인 스레드가 프로세스의 주 실행 스레드인 경우에만 해당된다. 이 경우, 메인 스레드에서 exit() 함수를 호출하면 프로세스가 종료될 수 있다)

 

그러나 다른 분리된(detached) 스레드가 실행 중이라면, 프로세스는 종료 전에 모든 스레드가 종료되기를 기다린다.

(이부분도 엄밀히 말하자면 분리된 스레드는 프로세스의 나머지 부분과 독립적으로 종료될 수 있으며, 프로세스는 분리된 스레드가 모두 종료될 때까지 자동으로 기다리지 않는다. 분리된 스레드는 자신의 자원을 스스로 관리하고 종료 시 자원을 해제한다. 프로세스는 exit() 호출 시 모든 스레드가 즉시 종료되도록 요청할 수 있지만, 분리된 스레드들이 종료되기를 기다리지는 않는다)

 

스레드를 생성하는 프로세스는 커널 프로세스일 수 있다. 동시에, 사용자 공간에서 개시된 사용자 프로세스일 수도 있다. 만약 프로세스가 커널이라면, 스레드는 커널 수준 스레드(kernel-level thread) 또는 커널 스레드(kernel thread) 라고 한다. 커널이 아니라면, 이 스레드는 사용자 수준 스레드(user-level thread)라 한다. 커널 스레드는 대개 중요한 로직을 실행하므로 사용자 스레드보다 우선순위가 높다. 예를 들면 장치 드라이버는 하드웨어 신호를 기다리기 위해 커널 스레드를 사용한다.

 

같은 메모리 영역에 접근하는 사용자 스레드와 비슷하게, 커널 스레드도 커널의 메모리 공간에 접근할 수 있다. 따라서 커널 내의 모든 절차(procedure) 와 유닛에도 접근할 수 있다.

 

- 이 후의 스레드 설명은 커널 스레드보단 사용자 스레드를 주로 설명하고자 한다- 

(사용자 스레드로 작업하는 데 필요한 API 를 POSIX 표준에서 제공을 해주나 커널 스레드를 생성하고 관리하는 표준 인터페이스는 없어 각 커널이 스레드를 개별적으로 관리하기 때문에)

 

사용자는 직접 스레들를 생성할 수 없다. 사용자는 먼저 프로세스를 스폰해야 하며, 이후 해당 프로세스의 메인 스레드가 다른 스레드를 개시할 수 있다. 참고로 스레드만이 스레드를 생성할 수 있다.

 

스레드의 메모리 레이아웃에 설명하자면 모든 스레드는 고유 스택 메모리 영역이 있다. 이 영역은 해당 스레드의 비공개 메모리 영역이라고 볼 수 있다. 하지만 실제로는 이 영역의 주소를 가리키는 포인터가 있을 땐 같은 프로세스 내에 있는 다른 스레드가 접근할 수 있다.

모든 스택 영역은 같은 프로세스의 메모리 공간 내에 있으며, 같은 포르세스 아느이 어느 스레드라도 업근할 수 있다는 것이다.

 

스레드 메모리 레이아웃 간단 설명 -> 스레드 (tistory.com)

 

동기화 기술 관련해서는 프로세스를 동기화하는 데 쓰였던 동일한 제어 메커니즘이 여러 스레드를 동기화할 때 사용될 수 있다. (세마포어, 뮤텍스, 조건 변수 등이 스레드 동기화에도 사용가능하다는 것)

 

프로그램의 스레드가 동기화되어 더 이상 데이터 경쟁이나 경쟁 상태가 나타나지 않을 떄 일반적으로 스레드 안전(thread-safe) 프로그램이라고 한다. 비슷하게 어떠한 동시성 문제도 일으키지 않고 멀티스레드 프로그램에서 쉽게 사용될 수 있는 라이브러리나 일련읜 함수도 스레드 안전 라이브러리(thread-safe-library)라고 한다. 

 

POSIX 스레딩 인터프에스에서 NTPL 구현에 관한 내용 -> https://man7.org/linux/man-pages/man7/pthreads.7.html

 


[POSIX 스레드]

- MS WINDOW와 같은 POSIX 비호환 OS 에는 이러한 목적으로 설계된 다른 API 가 있으며 해당 OS 의 문서에서 찾아 볼 수 있다. 예를 들어 MS WINDOW 의 경우 스레딩 API 는 Win32 API 로 알려진 Window API 에서 제공한다. 

 

Window 의 스레딩 API 관한 MS 문서 -> https://learn.microsoft.com/ko-kr/windows/win32/procthread/process-and-thread-functions

 

하지만 C11 에서는 스레드로 작업하는 통합된 API 를 기대한다. 작성한 코드가 POSIX 시스템에 대한 프로그램이든 비 POSIX 시스템애 대한 프로그램이든 간에 C11 이 제공한느 동일한 API 를 사용할 수 있어햐 한다는 것이다. 이 방식이 바람직하나 지금 시점에서 glibc 같은 여러 C 표준 구현간에 통합 API 에 대한 지원은 많지 않은 현실이다.

 

쉽게 말해 pthead 라이브러리는 POSIX 호환 OS 에서 멀티스레드 프로그램을 작성할 때 사용할 수 있는 헤더와 함수의 집합이다. 각 OS 는 pthead 라이브러리에 대한 자체 구현이 있다. 이러한 구현은 다른 POSIX 호환 OS 와는 완전히 다를 수 있으나, 결국에는 모두 같은 인터페이스(API)를 제공한다.

 

유명한 예시로 LINUX OS 용 pthead 라이브러리의 주요 구현물인 네이티브 POSIX 스레딩 라이브러리(NPTL, native POSIX threading library) 가 있다.

 

pthread API 가 서술항 대로, 모든 스레드 기능은 pthread.h 를 포함해 이용할 수 있다. 또한 semaphore.h 를 포함할 때만 사용한 수 있는 pthread 라이브러리에 대한 확장도 존재한다. 예를 들면 이러한 확장 중 하나는 특정 세마포어에 대한 작업을 포함하는데, 여기에는 세마포어를 생성하거나 초기화하거나 파괴하는 등의 작업이 있다.

 

POSIX 스레딩 라이브러리는 다음의 기능을 제공한다. 

 

스레드 생성, 결함, 분리를 포함하는 스레드 관리

뮤텍스

세마포어

조건 변수

스핀락과 재귀 락 같은 여러 종류의 잠금

 

이 기능들을 설명하려면 pthread_ 접두어로 시작해야 한다. 원래부터 POSIX 스레딩 라이브러리에 속하지 않았고 나중에 확장으로 추가된 세마포어만 제외하면 모든 pthread 함수는 이 접두어로 시작한다. 세마포어의 경우 함수들은 sem_ 접두어로 시작한다.

 


[POSIX 스레드 스폰]

 

다음 코드를 통해 출력에 문자열을 인쇄하는 간단한 스레드 생성해보자

 

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

//pthread 라이브러리를 사용하기 위한 POSIX 표준 헤더
#include <pthread.h>

// 이 함수는 별개의 스레드에 대한 몸체로 실행되어야 하는 로직을 포함한다.

void *thread_body(void *arg)
{
	printf("Hello from first thread!\n");
	return NULL;
}

int main(int argc, char **argv)
{
	//스레드 헨들러
	pthread_t thread;
	
	//새로운 스레드 생성
	int reasult = pthread_create(&thread, NULL, thread_body, NULL);
	
	// 스레드 생성 실패시
	if (result)
	{
		printf("Thread could not be created. Error number: %d\n", result);
		exit(1);
	}
	
	// 생성된 스레드가 종료되기를 대기
	result = pthread_join(thread, NULL);
	
	//스레드 결합에 성공하지 못한 경우
	if (result)
	{
		printf("The thread could not be joined. Error number: %d\n", result)
		exit(2);
	}
    
	return 0;
}

 

출력 결과

$ gcc ExtremeC_examples_chapter15-1.c -o ex15_1.out -lpthread
$ ./ex15_1.out
Hello from first thread!
$

 

 

가장 위에는 pthread.h 헤더 파일을 포함했다. 이는 모든 pthread 기능을 제공하는 표준 헤더 파일이다. pthread_create 와 pthread_join 함수 모두 선언하려면 이 페더 파일이 필요하다. 

 

main 함수 직전에 새 함수 thread_body 를 선언했는데, 이 함수는 특정 시그니처를 따른다. 이 함수는 void * 포인터를 받아서 다른 void * 포린터를 반환한다.

(void * 는 제네릭 포인터형으로 int * 또는 double * 와 같은 다른 포인터형도 나타낼 수 있다)

 

이 시그니처는 C 함수가 가질 수 있는 가장 일반적인 시그니처이다. POSIX 표준에서 규정한 바에 따르면, 스레드 로직으로 사용되는 스레드에 대해 동반자 함수(companion function) 가 되려는 함수는 모두 이러한 제네릭 시그치어를 따라야 한다. 그래서 thread_body 함수를 정의한 것이다.

(즉, 스레드를 통해서 실행하고자 하는 함수를 작성하여, pthread_create 의 세 번째 인자로 전달해주는 것)

 

main 함수의 첫 번째 명령어에서 pthread_t 형에 대한 변수를 선언했다. 스레드가 다루는 이 변수는 선언에 따라 다른 특정 스레드를 참조하지 않는다. 말하자면 이 변수는 아직 유효한 스레드 ID(TID) 가 할당되지 않았다. 이 변수는 스레드를 생성한 이후에만 새로 생성된 스레드에 대해 유효한 핸들을 포함한다.

(create_pthread 를 통해 스레드가 생성되어야 비로소 pthread_t 변수를 활용할 수 있다)

 

스레드 생성 이후 스레드 핸들은 최근에 생성된 스레드의 ID 를 참조한다. 스레드 ID 는 OS 에서 스레드 식별자에 해당하는 한편, 스레드 핸들은 프로그램 내 스레드를 대표한다. 대부분의 경우 스레드 핸들에 저장된 값은 스레드 ID 와 같다. 또한 모든 스레드는 자시 자신을 참조하는 pthread_t 변수를 획득해 스레드 ID 에 접근할 수 있다. 스레드는 pthread_self 함수를 사용해 자기 참조 핸들을 얻을 수도 있다.

 

스레드는 pthread_create 함수가 호출될 때 생성된다.

 

pthtread_init

 

첫 번째 인수thread 핸들 변수의 주소를 전달하는데 새로 생성된 스레드를 참조하는 알맞은 핸들(또는 스레드 ID)로 채우기 위해서이다.

 

두 번째 인수스레드의 속성을 결정한다. 모든 스레드는 스레드를 스폰하기 전에 설정할 수 있는 스택크키, 스택 주소, 분리(detach) 상태와 같은 속성이 있다.

NULL 을 두 번째 인자로 전달받았다면, 새 스레드가 속성에 NULL 이라는 기본값을 사용해야 한다는 의미이다.

 

세 번째 인수함수 포인터로 이는 스레드 로직을 포함하는 스레드의 동반자 함수(companion function) 를 가리킨다. 위의 코드에서 thread_body 함수를 정의하고, 핸들 변수 thread 로 바인딩 되기 위해 스레드 함수의 주소가 전달되어야 한다.

 

네 번째 인수스레드 로직에 대한 입력 인수로, 위 코드에선 NULL 로 전달함으로써 아무것도 전달하지 않겠다는 의미이다. 그러므로 thread_body 함수에 있는 매개변수는 arg 는 스레드가 실행되자마자 NULL 이된다. 

(네 번째 인자는 스레드가 실행할 함수의 파라미터로 전달할 변수를 입력하면된다. 전달될 변수가 많다면 구조체를 생성하여 구조체 주소를 전달하는 방법도 있다)

 

pthread_create 를 포함한 모든 pthread 함수는 실행이 성공적이면, 0을 반환하는데, 0이 아닌 숫자가 반환된 경우는 함수가 실패했다는 의미이며 오류 숫자(error number) 가 반환된 것이다.

 

pthread_create 를 사용해서 스레드를 생성한다고 곧바로 스레드 로직이 실행되는 의미는 아니니 주의해야한다. 이는 스케줄링에 관한 문제로, 새 스레드가 CPU 코어를 언제 얻고 실행할지 예측할 수 없기 때문이다.

 

스레드가 생성되면 새로 생성된 스레드를 결합을 한다. ㅡ 그럼 결합이 무엇을 의미하는가?

 

각 프로세스는 메인 스레드라는 단 하나의 스레드로 시작한다. 부모가 소유자 프로세스인 메인 스레드를 제외하면, 다른 스레드는 모두 부모 스레드가(parent thread)가 존재한다. 기본적인 경우에는 만약 메인 스레드가 종료되면 프로세스 역시 종료된다. 프로세스가 종료되면 실행 중이거나 잠자던 다른 모든 스레드 또한 즉시 종료된다.

 

그러니 새 스레드가 생성되었지만 아직 CPU 의 사용 허가를 획득하지 못해서 스레드가 시작되지 않았을 때, 그 사이 모종의 이유로 부모 프로세스가 종료된다면 스레드는 첫 번째 명령어를 시작하기도 전에 종료된다. 따라서 메인 스레드는 두 번째 스레드가 실행되고 결합이 완료될 때까지 대기해야한다.

(결합이라고 표현이 되었는데 pthread_join 함수는 wait 함수와 같은 매커니즘으로 자식 스레드가 전부 종료될 때까지 부모 스레드가 기다리게 하는 함수이다. 메인 스레드에서 분리된 스레드의 결과를 종료 후에 메인 스레드에 반영해야 하기 때문에 표현한 것으로 보인다) 

 

스레드는 동반자 함수가 반환될 때만 종료된다. 위의 코드에서 스폰된 스레드는 thread_body 동반자 함수가 반환될 때 종료되는데, 이는 함수가 NULL 을 반환할 때 이루어진다. 새로 스폰된 스레드가 종료되면 pthread_join 호출 이후에 블로킹된 메인 스레드가 해제되어 계속 진행이 가능해져 프로그램이 성공적으로 종료된다.

 

출력 결과에 보면 컴파일 명령어에 대한 옵션느올 -lpthread 를 입력한 것을 볼 수 있다. 프로그램을 pthread 라이브러리의 기존 구현과 링크하기 위해선 해당 플래그를 입력해야 한다. macOS 같은 플랫폼에선 -lpthread 옵션 없이도 프로그램을 링크할 수 있다. 그렇지만 pthread 라이브러리르 사용할 떄는 이 옵션을 사용하기를 권장한다.

C 프로잭트를 빌드할 때, 모든 플랫폼에서 작동하도록 빌드 스크립트(build script) 를 만들고 교차 오환성 문제를 방지하려면 중요한 부분이기 때문이다.

 

스레드는 기본적으로 결합할 수 있는데, 결합할 수 있는 스레드를 결합 가능한(joinable) 스레드라고 부른다. 반대로는 분리된(detached) 스레드가 있다. 

 

메인 스레드는 새로 스폰된 스레드를 결합하는 대신 분리할 수 있다. 분리된 스레드가 종료되기 전에 스스로 종료하도록, 분리된 스레드를 대기해야 한다고 프로세스에 알려줄 수 있다. 참고로 이때는 부모 프로세스가 종료되지 않고도 메인 스레드가 종료될 수 있다.

 

분리된 스레드를 통해 메인 스레드가 이미 종료되었더라도 두 번째 스레드가 종료될 때까지 계속 실행되는 코드의 예시를 작성해보자

 

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

//pthread 라이브러리를 사용하기 위한 POSIX 표준 헤더
#include <pthread.h>

// 이 함수는 별개의 스레드에 대한 몸체로 실행되어야 하는 로직을 포함한다.

void *thread_body(void *arg)
{
	printf("Hello from first thread!\n");
	return NULL;
}

int main(int argc, char **argv)
{
	//스레드 헨들러
	pthread_t thread;
	
	//새로운 스레드 생성
	int reasult = pthread_create(&thread, NULL, thread_body, NULL);
	
	// 스레드 생성 실패시
	if (result)
	{
		printf("Thread could not be created. Error number: %d\n", result);
		exit(1);
	}
	
	// 스레드 분리
	result = pthread_detach(thread);
	
	//스레드 분리에 성공하지 못한 경우
	if (result)
	{
		printf("The thread could not be detached. Error number: %d\n", result)
		exit(2);
	}
    
	// 메인 스레드 종료
	pthread_exit(NULL);
    
	return 0;
}

 

결합 가능한 스레드를 활용한 코드와의 차이점은 새로 생성된 스레드를 관리하는 방식이다.

 

새 스레드를 생성한 직후 메인 스레드는 이를 분리한다. 그런 다음 메인 스레드는 종료된다. 명령어 pthread_exit(NULL) 은 다른 분리된 스레드가 종료될 때까지 프로세스가 대기해야 한다고 알려주려면 필요하다. 스레드가 분리되지 않는다면 프로세스는 메인 스레드가 끝나자마자 종료된다.

 


[경쟁 상태에 대한 예제]

 

다음 코드를 통해 문제가 많이 발생하는 모습을 구현하고자 한다. 코드의 최종 결과를 얼마나 안정적으로 예측할 수 없는지를 보여주며, 주로 동시 시스템의 비결정적 속성으로 인해 발생하는 모습을 확인할 수 있다. 

 

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

//pthread 라이브러리를 사용하기 위한 POSIX 표준 헤더
#include <pthread.h>

void * thread_body(void *arg)
{
	char *str = (char *)arg;
	printf("%s\n", str);
	return NULL;
}

int main(int argc, char **argv)
{
	// 스레드 핸들러
	pthread_t thread1;
	pthread_t thread2;
	pthread_t thread3;
    
	// 새 스레드 생성
	int result1 = pthread_create(&thread1, NULL, thread_body, "Apple");
	int result2 = pthread_create(&thread2, NULL, thread_body, "Orange");
	int result3 = pthread_create(&thread3, NULL, thread_body, "Lemon");
    
	if (result1 || result2 || result3)
	{
		printf("The threads could not be create.\n");
		exit(1);
	}
    
	// 생성된 스레드가 종료되기를 대기
	result1 = pthread_join(thread1, NULL);
	result2 = pthread_join(thread2, NULL);
	result3 = pthread_join(thread3, NULL);
    
	if (result1 || result2 || result3)
	{
		printf("The threads could not be joined.\n");
		exit(2);
	}
    
	return 0;
}

 

이 코드에서 볼 수 있듯 pthread_create ㅎ마수에 네 번째 인수를 전달했다. 이 인수들은 thread_body 동반자 함수에 있는 제네릭 포인터 매개변수 arg 를 통해 스레드가 접근할 수 있다.

 

thread_body 함수에서 스레드는 제네릭 포인터 arg 를 char * 포인터로 형변환하고 printf 함수를 사용해 해당 주소에서 시작하는 문자열을 출력한다. 바로 이러한 방식을 통해 스레드로 인수를 전달할 수 있다. 마찬가지로 한 개의 포인터만 전달하므로 인수들인 얼마나 크든 상관없다.

 

만약 생성할 때 스레드로 보낼 값이 여러 개라면, 이 값들을 포함하는 구조체를 사용할 수 있고, 원하는 값으로 채운 구조체 변수에 대한 포인터를 전달할 수 있다. 

 

------------------------------------------------------------------------[ 여담 ]------------------------------------------------------------------------

 

스레드로 포인터를 전달할 수 있다는 것은 메인 스레드가 접근할 수 있는 메모리 영역과 같은 영역에 새로운 스레드가 접할 수 있어야 한다는 의미이다. 하지만 소유자 프로세스의 메모리 내부의 특정 세그먼트나 영역에 접근이 제한되지는 않으며, 모든 스레드는 프로세스 내의 스택, 힙, 텍스트. 데이터 세그먼트에 완전한 접근 권한을 갖는다.

 

---------------------------------------------------------------------------------------------------------------------------------------------------------

 

위의 코드를 실행하면 결과가 매번 동일하게 나오지 않음을 알 수 있다.

출력 결과

$ gcc ExtremeC_examples_chapter15_2.c -o ex15_2.out -lpthread
$ ./ex15_2.out
Apple
Orange
Lemon
$
$ ./ex15_2.out
Orange
Apple
Lemon
$
$ ./ex15_2.out
Apple
Orange
Lemon
$

 

첫 번째와 두 번째 스레드가 세 번째 스레드보다 먼저 문자열을 출력하는 인터리빙을 만들기는 쉽다. 하지만 첫 번째 또는 두 번째 문자열로 세 번째 문자열 Lemon 을 출력하는 인터리빙을 만들기는 어렵다. 다만, 확률은 낮더라도 발생할 수는 있다. 이러한 인터리빙을 만들려면 예제를 훨 씬 더 많이 실행해야 될 수도 있다.

 

위크 코드는 보이는데로 스레드 안전이라고 할 수 없다. 

멀티스레드 프로그램이 스레드 안전이라면, 정의된 불변 제약 조건에 따라 경쟁 상태도 없다. 하지만 위의 코드는 경쟁 상태가 존재하므로 스레드 안전이 아닌 것이다.

 

위의 결과를 보면 Applel 또는 Orange 문자열 사이에는 인터리빙이 없기 때문에 다음과 같은 결과는 발생하지 않는다

출력 예시

$ ./ex15_2.out
AppOrle
Ange
Lemon
$

 

셸 박스의 내용은 'printf 함수'가 스레드 안전(thread safe)임을 나타낸다. 쉽게 말해 인터리빙이 어떻게 발생하는지는 중요하지 않으며, 스레드 중 하나가 문자열을 출력하는 중이라면 다른 스레드에 있는 printf 인스턴스는 아무것도 출력하지 않는다.

(printf 의 버퍼 사용에는 동기화 메커니즘이 있어서 위의 출력 결과처럼 데이터 경쟁이 발생하지 않는다는 것)

 

코드의 동반자 함수 thread_body 는 세 개의 다른 스레드에 대한 문맥에서 세 번 실행되었다. 

(블로그의 동기화2, 3 의 의사 코드에선 모두 메인 스레드에서 실행되었으나, 위의 코드는 메인 스레드가 아닌 각각의 자식 스레드들이 출력문을 실행한 것)

 

두 개의 스레드가 하나의 함수 호출을 시작할 수는 없다. 이유는 확실하다. 각 함수는 오직 하나의 스레드의 스택에 쌓을 스택 프레임만 만들어야 하고, 서로 다른 두 개의 스레드에는 두 개의 다른 스택 영역이 있기 때문이다. 그러므로 함수 호출은 하나의 스레드에서만 시작할 수 있다. 즉, 두 스레드는 같은 함수를 각각 호출할 수 있으며 그 결과 함수는 두 번 개별적으로 호출된다. 하지만 두 스레드가 같은 함수 호출을 공유할 수는 없다.

(스레드들은 각각 자신만의 스택 프레임을 갖기 때문에 같은 함수를 호출하더라도, 서로의 스택 프레임에 생성되므로 별개의 함수를 호출한 것이다. 또한 각각이 독립성을 갖기 때문에 당연하게도 함수를 공유할 수 없다는 것으로 printf 경우 버퍼 내용을 서로가 공유할 수 없는 것을 의미한다)

 

스레드에 전달된 포인터는 허상 포인터가 아니어야 한다는 점을 유의해야 한다. 허상 포인터가 전달되면 추적하기 어려운 심각한 메모리 문제가 발생한다. 허상 포인터는 할당된 변수가 없는 메모리 주소를 가리킨다. 더 구체적으로는 변수나 배열이 원래는 존재했을 수 있지만, 포인터가 사용되려는 시점에 이미 해제된 경우가 이에 해당한다.

위의 코드에선 각 스레드에게 세 개의 리터럴(literal)을 전달했다. 이러한 문자열 리터럴이 필요한 메모리는 힙이다 스택이 아니라 데이터 세그먼트에서 할당되므로, 리터럴의 주소는 절대 해제되지 않으며 arg 도 허상 포인터가 되지 않는다.

 

다음은 허상 포인터가가 있는 코드로, 잘못된 메모리 동작으로 이어지는 것을 확인할 수 있다.

 

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

//pthread 라이브러리를 사용하기 위한 POSIX 표준 헤더
#include <pthread.h>

void * thread_body(void *arg)
{
	char *str = (char *)arg;
	printf("%s\n", str);
	return NULL;
}

int main(int argc, char **argv)
{
	// 스레드 핸들러
	pthread_t thread1;
	pthread_t thread2;
	ptrhead_t thread3;
    
	char str1[8], str2[8], str3[8];
	strcpy(str1, "Apple");
	strcpy(str2, "Orange");
	strcpy(str3, "Lemon");

	// 새 스레드 생성
	int result1 = pthread_create(&thread1, NULL, thread_body, str1);
	int result2 = pthread_create(&thread2, NULL, thread_body, str2);
	int result3 = pthread_create(&thread3, NULL, thread_body, str3);
    
	if (result1 || result2 || result3)
	{
		printf("The threads could not be create.\n");
		exit(1);
	}
    
	// 스레드 분리
	result1 = pthread_detach(thread1);
	result2 = pthread_detach(thread2);
	result3 = pthread_detach(thread3);
    
	if (result1 || result2 || result3)
	{
		printf("The threads could not be detached.\n");
		exit(2);
	}

	// 이제 문자열은 할당이 해제됩니다.
	pthread_exit(NULL);
    
	return 0;
}

 

위의 코드와 이전 코드의 차이점은 데이터 세그먼테에 탑재된 리터럴을 가리키는게 아닌 메인 스레드의 스택 영역에 할당된 문자열을 가리킨다. main 함수에서 이 배열이 선언되었으며 그 다음 행에서 이 배열에 문자열 리터럴이 추가되었다.

문자열 리터럴은 아직 데이터 세그먼트에 탑재되어 있지만, 선언되 배열은 이제 strcpy 함수를 사용해서 추가된 이후 문자열 리터럴과 같은 값을 갖는다.

 

또한 메인 스레드가 행동하는 방식도 차이를 뒀는데, 결합이 아닌 분리를 사용함으로써 메인 스레드가 즉시 종료될 수 있게 작성되어 있다. 그러면 메인 스레드의 스택 최상단에서 선언된 배열은 할당이 해제되며, 일부 인터리빙에서 다른 스레드들은 이 해제된 영역을 읽으려고 한다. 그러므로 일부 인터리빙에서는 스레드에 전달된 포린터가 허상 포인터가 될 수 있다.

 

허상 포인터를 감지하려면 메모리 프로파일러를 사용해야 한다. 더 간단하게는 프로그램을 여러 번 실행해 충돌이 발생하기를 기다리는 방법도 있지만, 허상 포인터를 사용해 그 내용에 접근한다고 해서 반드시 충돌이 방생하진 않기 때문에 언제 발생할 지 모르는 충돌을 기다려야 하므로 비효율적이다.

위의 코드 실행의 잘못된 메모리 동작을 감지하기 위해 valgrind 를 사용하면 좀 더 쉽게 확인할 수 있다.

 

다른 스레드가 실행 중인 동안 스택 세그먼트는 메인 스레드가 종료될 때까지 같은 상태로 남는다. 따라서 main 함수를 떠날 때 str1, str2, str3 배열이 해제되더라도 문자열에 접근할 수 있다. 즉, C 나 C++ 에서 런타임 환경은 포인터가 허상인지 아닌지 검사하지 않으며 단지 구문의 순서만 따른다.

 

포인터가 허상이고 그에 따른 메모리가 변경된다면, 충돌이나 논리적 오류 같은 좋지 못한 일이 발생할 수 있다. 한편 메모리 상태가 변경되지 않은(untouched) 경우, 허상 포인터는 충돌로 이어지지 않을 수 있으나 이는 매우 위험하고 추적하기 어렵다.

즉, 허상 포인터로 메모리 영역에 접근할 수 있다고 해서 그 영역에 대한 접근이 허용된다는 의미는 아니다. 이런 이유로 유효하지 않은 메모리 접근을 보고하는 valgrind 같은 메모리 프로파일러를 사용해야 한다.

 

다음은 valgrind 로 프로그램을 컴파일하고 두 번 실행한 결과이다. 첫 번째 실행에선 문제가 발생하지 않지만, 두 번째 실행에서는 valgrind 가 잘못된 메모리 접근을 보고한다.

 

출력 결과 1

$ gcc ExtremeC_examples_chapter15_2_1.c -o ex15_2_1.out -lpthread
$ ./ex15_2_1.out
==1842== Memcheck, a memory error detector
==1842== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==1842== Using Valgrind-3.13.0 and LibVEXl; rerun with -h for copyright info
==1842== Command: ./ex15_2_1.out
==1842==
Orange
Apple
Lemon
==1842==
==1842== HEAP SUMMARY
==1842== in use at exit: 0 byte in 0 blocks
==1842== total heap usage: 9 allocs, 9 frees, 3,534 bytes allocated
==1842==
==1842== All heap blocks were freed -- no leaks are possible
==1842==
==1842== For counts of deteched and suppressed errors, rerun with: -v
==1842== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
$

 

출력 결과 2

$ gcc ExtremeC_examples_chapter15_2_1.c -o ex15_2_1.out -lpthread
$ ./ex15_2_1.out
==1842== Memcheck, a memory error detector
==1842== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==1842== Using Valgrind-3.13.0 and LibVEXl; rerun with -h for copyright info
==1842== Command: ./ex15_2_1.out
==1842==
Apple
Lemon
==1842==
==1842== Thread 4:
==1842== Conditional jump or move depends on uninitialised value(s)
==1842==	at 0x50E6A65: _IO_file_xsputn@@GLIBC_2.2.5 (fileops.c:1241)
==1842==	by 0x50DBA8E: puts (ioputs.c:40)
==1842==	by 0x1087Co: thread_body (ExtremeC_examples_chapter15_2_1.c:17)
==1842==	by 0x4E436DA: start_thread (pthread_create.c:463)
==1842==	by 0x517C88E: clone (clone.S:95)
==1842==
...
==1842== Syscall param write(buf) points to uninitialised byte(s)
==1842==	at 0x516B187: write (write.c:27)
==1842==	by 0x50E61BC: _IO_file_write@@GLIBC_2.2.5 (fileops.c:1203)
==1842==	by 0x50E7F50: new_do_write (fileops.c:457)
==1842==	by 0x50E7F50: _IO_do_write@@GLIBC_2.2.5 (fileops.c:433)
==1842==	by 0x50E8402: _IO_file_overflow@@GLIBC_2.2.5 (filepos.c:798)
==1842==	by 0x50DBB61: puts(ioputs.c:41)
==1842==	by 0x1087C9: thread_body (ExtremeC_examples_chapter15_2_1.c:17)
==1842==	by 0x4E436DA: start_thread (pthread_create.c:463)
==1842==	by 0x517C88E: clone(clone.S:95)
...
==1842==
Orange   // 유효하지 않은 접근임에도 불구하고 출력문 출력
==1842==
==1842== HEAP SUMMARY:
==1842==	  in use at exit: 0 byte in 0 blocks
==1842==	total heap usage: 9 allocs, 9 frees, 3,534 bytes allocated
==1842==
==1842== LEAK SUMMARY:
==1842==	definitely lost: 0 bytes in 0 blocks
==1842==	indirectly lost: 0 bytes in 0 blocks
==1842==	  possibly lost: 272 bytes in 1 blocks
==1842==	still reachable: 0 bytes in 0 blocks
==1842==	    suppressed: 0 bytes in 0. blocks
==1842== Rerun with --leak-check-full to see details of leaked memory
==1842==
==1842== For counts of deteched and suppressed errors, rerun with: -v
==1842== Use --track-origins=yes to see where nuinitialised values com from
==1842== ERROR SUMMARY: 13 errors from 3 contexts (suppressed: 0 from 0)
$

 

첫 번째 실행에서는 경쟁 상태가 있더라도 메모리 접근 문제가 발생하지 않은 채 실행되었지만 두 번째 실행에서는 스레드 중 한다가 str2 가 가리키는 문자열 'Orange' 에 접근하려고 할 때 문제가 발생한다.

 

즉, 두 번째 스레드로 전달된 포인터가 허상이라는 뜻이다. 앞의 출력 결과에서 printf 구문의 thread_body 함수에 있는 행을 스택 추적이 가리킴을 확실히 확인할 수 있다. 참고로 스택 추적은 실제로 puts 함수를 나타내는데, C 컴파일러가 printf 구문을 puts 구문으로 바꾸었기 때문이다. 앞의 출력 결과 또한 write 시스템 호출이 buf 라응 포인터를 사용함을 나타낸다. 이 포인터는 초기화 및 할당이 되지 않은 메모리 영역을 가리킨다.

 

잘못된 메모리 문제에 대한 오류 메시지 이전에, 문자열 Orange 를 읽으려는 접근이 유효하지 않은데도 문자열이 출력된 것을 볼 수 있다. 이는 valgrind 가 포인터의 허상 여부를 판단을 하는 것이 아닌 단순히 유요하지 않은 메모리 문제만을 보고한다는 것을 알 수 있는 대목이며, 동시적인 방식으로 코드를 실행할 때 얼마나 쉽게 복잡해질 수 있는지를 보여주는 예시 결과이다.

 


[데이터 경쟁에 대한 예]

- 이번에 보여드리고자 하는 코드는 공유 변수를 통해 데이터 경쟁을 시연하는 코드이다. 이 예제에 대한 불변 제약 조건은 <충돌이 없어야하고, 잘못된 메모리 접근이 없어야 한다는 등의 다른 모든 명백한 제약에 더해(보통적으로 요구되는 불변 제약) 공유 상태에 대한 데이터 무결설 보호> 이다.

 

즉, 출력은 어떻게 나오는지는 중요하지 않지만 다른 스레드가 공유 변숫값을 변경하는 동안, 그리고 그 값을 작성하는 스레드가 최신값을 모르는 동안에는 새 값을 작성할 수 없는 조건이고, 이것이 데이터 무결성이다.

 

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

//pthread 라이브러리를 사용하기 위한 POSIX 표준 헤더
#include <pthread.h>

void * thread_body_1(void *arg)
{
	// 공유 변수를 가리키는 포인터 획득
	int *shared_var_ptr = (int *)arg;
    
	//메모리 주소에 직접 작성할 때마다 공유 변수를 1씩 증가
	(*shared_var_ptr)++;
    
	printf("%d\n", *shared_var_ptr);
	return NULL;
}

void * thread_body_2(void *arg)
{
	// 공유 변수를 가리키는 포인터 획득
	int *shared_var_ptr = (int *)arg;
    
	//메모리 주소에 직접 작성할 때마다 공유 변수를 2씩 증가
	(*shared_var_ptr) += 2;
    
	printf("%d\n", *shared_var_ptr);
	return NULL;
}

int main(int argc, char **argv)
{
	// 공유 변수
	int shared_var = 0;
    
	// 스레드 핸들러
	pthread_t thread1;
	pthread_t thread2;

	// 새 스레드 생성
	int result1 = pthread_create(&thread1, NULL, thread_body_1, &shared_var);
	int result2 = pthread_create(&thread2, NULL, thread_body_2, &shared_var);
    
	if (result1 || result2)
	{
		printf("The threads could not be create.\n");
		exit(1);
	}
    
	// 스레드 종료를 대기
	result1 = pthread_join(thread1, NULL);
	result2 = pthread_join(thread2, NULL);
    
	if (result1 || result2)
	{
		printf("The threads could not be joined.\n");
		exit(2);
	}
    
	return 0;
}

 

이 코드에선 메인 함수에서 공유 상태를 하나 선언하여 메인 스레드의 스택 영역에서 할당된 정수 변수를 통해 경쟁 상태를 확인하지만 실제 응용 프로그램에선 훨씬 더 복잡할 수 있다.

 

현재 코드는 각 스레드에서 동유된 변숫값의 사본을 유지하는 지역 변수는 없기 때문에 스레드에서 증가 연산을 할 때는 조심해야 되는 것을 보여준다. 이 연산을 원자적인 연산이 아니며 따라서 다른 인터리빙을 겪을 수 있기 때문이다.

 

각 스레드 인수 arg 를 통해서 동반자 함수의 내부에서 값을 받는 포인터를 사용해서 공유된 변숫값을 변경할 수 있다. pthread_create 에 대한 두 번의 호출 모두 변수 shared_var_ptr 의 주소를 네 번째 인수로 전달했다.

 

메인 스레드는 종료되지 않은 채로 스레드들이 결합해 종료될 때까지 대리해야 하므로, 스레드에서 포린터는 항상 절ㄷ ㅐ허상 포인터가 되어서는 안된다는 점을 주의해야 한다.

 

위의 코드를 여러 번 실행한 출력결과는 다음과 같다. 

 

출력 결과 1

$ gcc ExtremeC_examples_chapter15_3.c -o ex15_3.out -lpthread
$ ./ex15_3.out
1
3
$
...
...
...
$ ./ex15_3.out
3
1
$
...
...
...
$ ./ex15_3.out
1
2
$

 

 마지막 실행에서 공유된 변수에 대한 데이터 무결성이 충족되지 않았음을 볼 수 있다.

 

마지막 실행에서, 동반자 함수로 thread_body_1 이 있는 첫 번째 스레드는 공유된 변숫값을 읽으며 이 값은 0 이다.

동방자 함수로 thread_body_2 가 있는 두 번째 스레드 또한 공유된 변숫값을 읽으며 그 값은 0 이다.

 

이 지점 수에 두 스레드는 공유된 변숫값을 증가시키려 하며 즉시 이를 출력한다. 이는 데이터 무결성을 위반하는 것으로, 한 스레드가 공유 상태의 값을 변경할 때 다른 스레드는 거기에 값을 작성할 수 없어야 하기 때문이다.

반응형

'C' 카테고리의 다른 글

스레드 동기화  (8) 2024.09.14
컴파일  (0) 2024.08.06