본문 바로가기

C

스레드 동기화

반응형

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

 

[POSIX 뮤텍스]

- pthread 라이브러리에 도입된 뮤텍스는 프로세스 및 스레드를 동기화하는 데 상요할 수 있다. 뮤텍스는 한 번에 하나의 스레드만 임계 구역에 들어갈 수 있도록 허용하는 세마포어이다. 일반적으로 세마포어는 자신의 임계 구역에 하나 이상의 스레드를 허용할 수 있다.

 

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

 

뮤텍스는 이진 세마포어라고도 불린다. 이유는 단 두가지 상태만 받는 세마포어이기 때문이다.

 

그러나 엄밀하게 구분한다면 뮤텍스와 이진 세마포어는 별개로 구분할 수 있다.

 

위에서 쓰여 있듯이 임계 구역에 하나 이상의 스레드가 들어갈 수 있는 세마포어와는 달리 뮤텍스는 '소유권' 이라는 개념을 갖기 때문에 단 하나의 스레드만 임계 구역에 들어갈 수 있기 때문이다.

 

그로 인해 뮤텍스는 주로 상호 배제를 위해 사용된다. 즉, 한 시점에 하나의 스레드만이 특정 자원에 접근할 수 있도록 한다.

반면에 세마포어는 신호 메커니즘으로 사용될 수 있다. 예를 들어, 하나의 스레드가 작업을 완료했다는 신호를 다른 스레드에게 전달할 때 사용할 수 있다.

 

 

위와 같은 내용으로 뮤테스는 데드락을 피하기 위한 복잡한 메커니즘이 필요할 수 있지만, 세마포어는 값이 0 or 1 로 제한되며, 이는 세마포어가 자원의 점유 여부만을 나타내는 간단한 형태로 작동한다.

 

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

 

뮤텍스는 임계 구역에 한 번에 한 개의 스레드만 들여보내서 공유 변수에 대한 읽기 및 쓰기 작업을 수행한다. 이러한 방식으로 뮤텍스는 공유 변수에 대한 데이터 무결성을 보장한다.

 

POSIX 뮤텍스를 사용해서 데이터 경쟁 문제를 해결하는 코드를 살펴보자

 

스레드2 (tistory.com) - 해당 글의 마지막에 나오는 데이터 경쟁 발생 코드에서 뮤텍스를 도입해 해결하는 과정을 보고자한다.

 

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

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

// 공유 상태에 대한 접근을 동기화하기 위해 사용된 뮤텍스 객체
pthread_mutex_t mtx;

void * thread_body_1(void *arg)
{
	// 공유 변수에 대한 포인터 얻기
	int *shared_var_ptr = (int *)arg;
    
    
	// 임계구역
	pthread_mutex_lock(&mtx);  // 잠금

	(*shared_var_ptr)++;    
	printf("%d\n", *shared_var_ptr);
    
	pthread_mutex_unlock(&mtx); // 잠금 해제
    
	return NULL;
}

void * thread_body_2(void *arg)
{
	int *shared_var_ptr = (int *)arg;
    
	// 임계구역
	pthread_mutex_lock(&mtx);  // 잠금

	*shared_var_ptr += 2;    
	printf("%d\n", *shared_var_ptr);
    
	pthread_mutex_unlock(&mtx); // 잠금 해제
    
	return NULL;
}

int main(int argc, char **argv)
{
	// 공유 변수
	int shared_var = 0;
    
	// 스레드 핸들러
	pthread_t thread1;
	pthread_t thread2;
    
	//뮤텍스 및 리소스 초기화
	pthread_mutex_init(&mtx, NULL);

	// 새 스레드 생성
	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 created.\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);
	}
    
	pthread_mutex_destroy(&mtx);
    
	return 0;
}

 

임계 구역에 POSIX mutex 객체를 사용했기 떄문에 이 코드를 컴파일해보면 출력이 1 3 또는 2 3 만 되는 것을 확인할 수 있다.

 

파일싀 시작 부분에 적연 POSIX 뮤텍스 객체를 mtx 로 선언했다. 그 다름 main 함수에서 pthread_mutex_init 함수를 사용해 뮤텍스를 기본 속성으로 초기화했다. 

 

뮤텍스는 pthread_mutex_lock(&mtx) 와 pthread_mutex_unlock(&mtx) 구문 사이에 있는 임계 구역을 보호하도록 두 스레드에서 모두 사용된다.

 

마지막으로 main 함수를 종료하기 전에 뮤텍스 객체를 삭제(destroy)한다.

 

동반자 함수 thread_body_1 에서 첫 번째 쌍을 이루는 pthread_mutex_lock(&mtx) 와 pthread_mutex_unlock(&mtx) 구문은 첫 번째 스레드에 대한 임계 구역을 구성한다. 또한, 동반자 함수 thread_body_2 에 있는 두 번째 쌍은 두 번째 스레드에 대한 임계 구역을 구성한다. 두 임계 구역은 모두 뮤텍스가 보호하며, 이 중 하나의 스레드만 임계 구역에 들어갈 수 있고 다른 스레드는 사용 중인 스레드가 떠날 때까지 임계 구역 바깥에서 대기해야 한다.

 

스레드가 임계 구역에 들어가자마자 스레드는 뮤텍스를 잠근다. 그리고 다른 스레드는 다시 뮤텍스의 잠금을 해제하기 위해 pthread_mutex_lock(&mtx) 구문 뒤에서 기다려야 한다.

 

기본적으로 뮤텍스의 잠금이 해제되기를 기다리는 스레드는 잠자기 모드로 들어가며 바쁜 대기를 하지 않는데 만약 잠자기 대신 바쁜 대기를 원한다면 스핀락을 사용할 수 있따. 앞의 뮤텍스 관련 함수를 사용하는 대신 다음 함수를 사용하는 것으로 충분하다. 다행히 pthread 는 이러한 함수에 일관적인 명명 규칙을 사용한다.

 

스핀락 관련 자료형과 함수는 다음과 같다.

 

  • pthread_spin_t : 스핀락 객체를 생성할 때 사용한다. pthread_mutex_t 형과 비슷한다.
  • pthread_spin_init : 스핀락 객체를 초기화한다. pthread_mutex_init 과 비슷하다.
  • pthread_spin_destroy : pthread_mutex_destroy 와 비슷하다.
  • pthread_spin_lock : pthread_mutex_lock 과 비슷하다
  • pthread_spin_unlock : pthread_mutex_unlock 과 비슷하다.

 

보다시피 뮤텍스 객체가 해제되기는 기다리는 동안, 앞의 뮤텍스 자료형과 함수가 바쁜 대기 등의 다른 동작을 하도록 이들을 스핀락이나 함수로 대체하는 일은 꽤 수월하다.

 


[POSIX 조건 변수]

 

이번엔 경쟁 상태에 대해 조건 변수를 활용하여 해결 하는 코드를 살펴보고자 한다.

 

스레드2 (tistory.com) - 이 글의 세 번째 예시 코드를 조건 변수로 경쟁 상태를 해결해보자 (글에서는 스레드가 3개 이나 해결 코드에선 2개 만으로 활용해보자)

 

다음의 코드의 제약 조건은 <출력에서 A 를 확이한 다음 B 를 확인> 이다. 나아가 모든 공유 상태에 대한 데이터 무결설이 지켜져야 하고, 메모리 접근이 잘못되지 않아야 하며 허상 포인터나 충돌이 생기면 안되고, 그 밖의 다른 명백한 제약 조건 또한 준수되어야 한다.

 

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

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

#define TRUE 1
#define FALSE 0

typedef unsigned int bool_t;

// 공유된 상태에 연관된 모든 변수를 담는 구조체
typedef struct
{
	// 'A'의 출력 여부를 가리키는 플래그
	bool_t done;
    
	//임계 구역을 보호하는 뮤텍스 객체
	pthread_mutex_t mtx;
    
	// 두 스레드를 동기화하는 데 사용되는 조건 변수
	pthread_cond_t cv;
}shared_state_t;

// shared_state_t 객체 멤버 초기화
void shared_state_init(shared_state_t *shared_state)
{
	shared_state->done = FALSE;
	pthread_mutex_init(&shared_state->mtx, NULL);
	pthread_cond_init(&shared_state-cv, NULL);
}

// shared_state_t 객체 멤버 삭제
void shared_state_destroy(shared_state_t *shared_state)
{
	pthread_mutex_destroy(&shared_state->mtx);
	pthread_cond_destroy(&shared_state->cv);
}

void * thread_body_1(void *arg)
{
	shared_state_t *ss = (shared_state_t *)arg;
    
	pthread_mutex_lock(&ss->mtx);
	printf("A\n");
	ss->done = TRUE;
    
	// 조건 변수를 대기하는 스레드에 신호 전달
	pthread_cond_signal(&ss->cv);
	pthread_mutex_unlock(&ss->mtx);
    
	return NULL;
}

void * thread_body_2(void *arg)
{
	shared_state_t *ss = (shared_state_t *)arg;
    
	pthread_mutex_lock(&ss->mtx);
	// 플래그가 TRUE 일 때까지 대기
	while (!ss->done)
	{
		// 조건 변수 대기
		pthread_cond_wait(&ss->cv, &ss->mtx);
	}
	printf("B\n");
	pthread_mutex_unlock(&ss->mtx);
    
	return NULL;
}


int main(int argc, char **argv)
{
	// 공유 상태
	shared_state_t shared_state;
    
	// 공유 상태 초기화
	shared_state_init(&shared_state);
    
    
	// 스레드 핸들러
	pthread_t thread1;
	pthread_t thread2;
    
	// 새 스레드 생성
	int result1 = pthread_create(&thread1, NULL, thread_body_1, &shared_state);
	int result2 = pthread_create(&thread2, NULL, thread_body_2, &shared_state);
    
	if (result1 || result2)
	{
		printf("The threads could not be created.\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);
	}
    
	// 공유 상태를 삭제하고 뮤텍스와 조건 변수 해제
	shared_state_destroy(&shared_state);
    
	return 0;
}

 

각 스레드에는 하나의 포인터만 전달할 수 있기 때문에 이 코드에선 공유 뮤텍스, 공유 조건 변수, 공유 플래그를 하나의 개체로 캡슐화하기 위한 구조체를 사용하였다.

 

자료형을 정의한 다음 shared_state_t 인스턴스를 초기화하고 삭제하기 위해 두 가지 함수를 정의했다. 이 함수들은 각각 shared_state_t 자료형에 대한 생성자(constructor)와 소멸자(destructor) 함수로 간주할 수 있다.

 

이것이 조건 변수를 사용하는 방법이다. 스레드는 조건 변수에서 대기(또는 잠자기)를 할 수 있고, 나중에 알림을 받고 깨어난다. 게다가 스레드는 조건 변수를 대기(또는 잠자기) 하는 다른 모든 스레드에 알림(또는 깨우기)을 할 수 있다. 이러한 모든 작업은 반듯이 뮤텍스에 의해 보호되어야 하므로, 항상 뮤텍스와 함께 조건 변수를 사용해야 한다.

즉, 공유 상태 객체에는 조건 변수를 보호하는 동반자 뮤텍스와 조건 변수가 함께 있어야 하고, 변수는 동반자 뮤텍스에 의해 보호받는 임계 구역에서만 사용해야 한다.

 

'A' 를 출력해야하는 스레드에서는 공유 상태 객체를 가리키는 포인터를 사용해 mtx 뮤텍스를 잠그려고 한다. 스레드는 락을 획득하면 'A' 를 출력하고 플래그를 done 으로 설정하며, 마지막으로 pthread_cond_signal 함수를 호출해 조건 변수 cv 에서 대기하고 있는 다른 스레드에 알린다.

(pthread_cond_signal 함수는 하나의 스레드에만 알리도록 사용할 수 있다. 조건 변수를 기다리는 모든 스레드에 알리려면 pthread_cond_broadcast 함수를 사용해야 한다)

 

반면, 두 번째 스레드가 활성화되고 첫 번째 스레드가 'A' 를 아직 출력하지 않을 때, 두 번째 스레드는 mtx 에 대한 잠금을 획들하려 시도한다. 성공했다면 두 번째 스레드는 플래그가 done 인지 확인한다. 실패했다면 그건 첫 번째 스레드가 아직 임계 구역에 진입하지 못했다는 의미이다(그렇지 않다면 플래그는 Ture 이어야 한다). 따라서 두 번째 스레드는 조건 변수를 기다리며 pthread_cond_wait 함수를 호출해 CPU 를 즉시 해제한다.

 

조건 변수를 대기하면 연결된 뮤텍스가 해제되어 다른 스레드가 계속될 수 있으니 주의해야 한다. 또한 활성 상태가 되어 대기 상태를 종료하면 연결된 뮤텍스를 다시 얻을 수 있어야 한다.

 

그럼 플래그가 done 인지 확인하려면 쉬운 if 문을 사용해도 되는데 왜 while 루프를 사용한 것일까?

 

두 번째 스레드는 첫 번째 스레드가 아닌 다른 소스에서 알림을 받을 수 있기 때문이다. 이러한 경우 스레드가 대기를 종료하자마자 뮤텍스에 대한 잠금을 획득해 활성 상태가 되면 루프의 조건을 검사할 수 있고, 아직 조건이 충족되지 않으면 다시 대기해야 한다. 대기 중인 조건과 일치할 때까지 루프 내의 조건 변수를 기다리는 것은 허용된 기법이다.

(조건 변수를 사용할 때 발생하는 '가짜 깨어남 (spurious wakeup) ' 및 다중 스레드 경쟁 상태를 안전하게 처리하기 위해서 사용되었다고 정리하면 될 것으로 보인다. if 문을 사용하면 스레드가 조건 변수에서 깨어났을 때 단 한 번만 조건을 검사하기 때문에 '가짜 깨어남'이 발생하거나 다른 스레드에 의해 상태가 변경되었다면, 조건이 충족되지 않음에도 불구하고 스레드가 계속 실행될 수 있고, 예상치 못한 '가짜 깨어남'이나 경쟁 상태로 인해 프로그램이 잘못된 행동을 할 가능성이 높아진다.

그렇기 때문에 while 문을 사용함으로써 스레드가 조건(ss->done == TRUE) 이 충족될 때까지 반복적으로 조건을 검사하고, 필요한 경우 다시 조건 변수에서 대기하도록 해서 경쟁 상태에서 발생할 수 있는 문제를 방지하고, 스레드가 조건이 충족되지 않은 상태에서 잘못 진행되는 것도 방지할 수 있다)

 

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

 

가짜 깨어남(spurious wakeup)은 조건 변수를 사용하는 멀티스레딩 프로그램에서 스레드가 명백한 이유 없이 조건 변수의 대기 상태에서 깨어나는 현상을 말한다. 이는 조건 변수의 내부 구현 또는 운영 체제의 스케줄링 동작 때문에 발생할 수 있으며, 특정한 이유 없이 발생하기 때문에 이를 처리하기 위한 로직이 필요하다.

 

가짜 깨어남의 정확한 원인은 다양하고, 대부분은 운영 체제의 내부 스케줄링 정책과 멀티스레딩의 복잡성에서 기인한다. 일반적인 원인은 다음과 같다

 

  • 운영 체제의 최적화: OS 가 시스템의 성능과 효율성을 개선하기 위해 스레드를 깨우는 것을 최적화하다 보면, 가짜 깨어남이 발생할 수 있다. OS 는 여러 스레드를 관리하면서 스레드 간의 컨텍스트 전환을 최소화하려고 시도할 수 있으며, 이 과정에서 스레드가 불필요하게 깨어나는 경우가 있다.
  • 신호 누락: 여러 스레드가 동시에 조건 변수를 사용하고 있을 때, 한 스레드가 조건 변수에 신호를 보내고 다른 스레드가 이를 기다리고 있을 경우, 신호가 적절히 전달되지 않아 스레드가 예상치 못하게 깨어날 수 있다.
  • 리소스 경합: 멀티스레드 환경에서는 리소스 경합이 일어날 수 있고, 이 과정에서 스레드의 대기 상태가 올바르게 관리되지 않아 가짜 깨어남이 발생할 수 있다.
  • 하드웨어 인터럽트: H/W 인터럽트나 다른 낮은 수준의 시스템 이벤트가 스레드의 대기 상태를 방해할 수 있다.

 

가짜 깨어남을 처리하는 방법

  • 반복적 조건 검사: while 루프를 사용하여 조건을 반복적으로 검사함으로써, 스레드가 실제로 원하는 조건이 충족될 때까지 대기하게 한다. 이는 스레드가 깨어날 때마다 조건을 확인하여, 실제 조건이 충족되었는지를 검증하고, 충족되지 않았다면 다시 대기하도록 한다.

가짜 깨어남을 적절히 처리하지 않으면, 스레드가 예상치 못한 시점에 실행되어 프로그램의 동작이 부정확해지거나 예측 불가능한 버그가 발생할 수 있다. 따라서 멀티스레드 프로그램을 설계할 때는 이러한 현상을 고려하여 안전하게 스레드를 관리할 수 있는 로직을 구현하는 것이 중요하다.

 

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

 

이러한 해결 방법은 메모리 가시성에 대한 제약도 만족한다. 모든 잠금 및 해제 작업은 여러 CPU 코어 사이에서 수월하게 메모리 일관성을 유지할 수 있다. 그러므로 캐시된 버전이 서로 다른 플래그 done 에서 확일 할 수 있는 값은 언제나 최신이며 서로 같다.

 

메모리 일관성 간단 설명 -> 동기화3 (tistory.com)

 


[POSIX 장벽]

- POSIX 장벽은 수많은 스레드를 동기화하기 위해 또 다른 접근법을 사용한다. 마치 한 무리의 사람이 어떤 일을 병렬적으로 할 계획을 세운 뒤에 특정 지점에 집합하고 재조직하고 게속하는 것처럼, 스레드(또는 프로세스)에도 같은 일이 발생할 수 있다. 어떤 스레드는 작업을 더 빠르게 하고, 다른 스레드는 더 느리게 한다. 그런데 모든 스레드가 다른 스레드가 결합하기를 기다려야하는 체크포인트(혹은 집합지점 rendezvous point)가 있을 수 있다. 이러한 체크포인트는 POSIX 장벽을 사용해 시뮬레이션 할 수 있다.

 

아래 코드는 장벽을 사용해 위의 코드의 결과와 같은 <A 를 출력 후 B 를 출력> 하는 결과를 얻는 방법을 보여준다.

(pthread_barrier 는 macOS 에선 지원되지 않기 때문에 컴파일 되지 않는점을 참고해야 한다)

 

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

#include <pthread.h>

// 장벽 객체
pthread_barrier_t barrier;

void * thread_body_1(void *arg)
{
	printf("A\n");
    
	// 다른 스레드의 결합 대기
	pthread_barrier_wait(&barrier);
    
	return NULL;
}

void * thread_body_2(void *arg)
{
	// 다른 스레드의 결합 대기
	pthread_barrier_wait(&barrier);
	printf("B\n");

	return NULL;
}

int main(int argc, char **argv)
{
	// 장벽 객체 초기화
	pthread_barrier_init(&barrier, NULL, 2);
   
	// 스레드 핸들러
	pthread_t thread1;
	pthread_t thread2;
    
	// 새 스레드 생성
	int result1 = pthread_create(&thread1, NULL, thread_body_1, NULL);
	int result2 = pthread_create(&thread2, NULL, thread_body_2, NULL);
    
	if (result1 || result2)
	{
		printf("The threads could not be created.\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);
	}
    
	// 장벽 객체 해제
	pthread_barrier_destroy(&barrier);
    
	return 0;
}

 

조건 변수로 작성한 코드보다 훨씬 짧으며 POSIX 장벽을 사용하면 실행하는 동안 특정 지점에서 스레드를 동기화하기 매우 수월하다. 

 

먼저 pthread_barrier_t 자료형에 대한 전역 장벽 객체를 선언했고, 그 다름 main 함수에서 pthread_barrier_init 함수를 사용해 장벽 객체를 초기화 하였다.

 

pthread_barrier_init 

 

첫 번째 인수장벽 객체에 대한 포인터이다.

두 번째 인수는 장벽 객체에 대한 사용자 지정 속성으로 NULL 을 전달시 장벽 객체가 갖는 기본값 속송으로 장벽 객체가 초기화된다.

세 번째 인수는 중요한 요소로 pthread_barrier_wait 함수를 호출해서 같은 장벽 객체에 대해 대기해야하는 스레드의 숫자이며, 이러한 스레드가 모두 해제되어야 계속할 수 있다.

 

위의 코드에선 세 번째 인수를 2로 설정했다. 따라서 장벽 객체를 기다리는 스레드가 두 개일때만 스레드의 잠금이 모두 해제되고 계속될 수 있다.

 

(여러 개의 스레드를 생성했을 때, pthread_barrier_init 의 인수로 주어진 수 만큼의 스레드들이 pthread_barrier_wait 함수에 도달해야 비로소 다음 코드로 동시에 진행을 할 수 있다. 이 말은 인수보다 스레드 수가 적으면 무한 대기에 걸리게 되는 것이고, 반대로 인수보다 스레드 수가 많으면 데이터 경쟁이나 경쟁 상태에서 동시성 문제가 발생 할 수 있다는 것이다)

 

장벽 객체는 뮤텍스와 조건 변수를 사용해서 구현할 수 있다. 사실 POSIX 호환 OS는 시스템 호출 인터페이스에서 장벽과 같은 것을 제공하지는 않으며, 대부분은 뮤텍스와 조건 변수를 사용해 구현된다.

이러한 이유로 macOS 와 같은 일부 OS 에서는 POSIX 장벽에 대한 구현을 기본적으로 제공하지 않는다. macOS 머신에는 POSIX 장벽 함수가 정의되지 않았으므로 위의 코드는 maxOS 에서 컴파일할 수 없고, 리눅스 및 FreeBSD 에서는 작동을 한다. 이와 같은 이유로 호환 OS 가 제한되어 있으니 POSIX 장벽을 사용하면 코드의 이식성이 낮아지므로 신중히 사용해야 한다.

 

POSIX 장벽은 메모리 가시성을 보장한다. 잠금  및 해제 작업과 비슷하게 장벽에 대한 대기는 같은 변수에 대한 모든 캐시된 버전이 장벽 지점을 떠나려고 할 때 여러 스레드에서도 동기화되도록 한다.

 

반응형

'C' 카테고리의 다른 글

스레드2  (0) 2024.09.14
컴파일  (0) 2024.08.06