C++의 경쟁 조건

Abdul Mateen 2023년10월12일
  1. 스레드란?
  2. 레이스 컨디션이란?
  3. 경쟁 조건 방지
  4. Mutex를 사용하여 경쟁 조건 방지
  5. 세마포어를 사용하여 경쟁 조건 방지
  6. 결론
C++의 경쟁 조건

이 튜토리얼에서는 경쟁 조건의 개념에 대해 배웁니다. 먼저 쓰레드에 대해 소개하고 예제를 통해 쓰레드의 경쟁 조건에 대해 논의할 것입니다.

마지막으로 경합 상태를 피/제어하는 솔루션을 살펴보겠습니다.

스레드란?

스레드는 경량 프로세스입니다. 프로세스는 프로세스의 많은 리소스를 공유하는 여러 스레드를 가질 수 있습니다.

스레드는 협력하기 위한 것입니다. 따라서 다른 리소스와 메모리/변수를 공유합니다.

스레드는 프로세스 내에서 동시 작업을 실행하는 데 사용됩니다. 예를 들어 MS Word 처리 프로세스에는 동시에 수행되는 여러 작업이 있습니다.

이러한 작업에는 맞춤법, 문법, 자동 저장, 키워드 레이블 지정, 들여쓰기 및 서식 지정이 포함됩니다. 별도의 스레드를 사용하여 이러한 동시 작업을 동시에 실행할 수 있습니다.

동시 정렬 및 인쇄

여기에서는 스레드를 더 잘 이해하기 위한 하나의 코딩 예제에 대해 설명합니다. 많은 값을 정렬하고 출력해야 한다고 가정합니다.

먼저 인쇄하기 전에 완전히 분류해야 합니다.

그러나 각 반복에서 목록의 시작 부분에 더 작은 요소가 표시되도록 숫자를 정렬하는 선택 정렬과 같은 일부 정렬 기술이 있습니다. 따라서 병렬로 인쇄를 시작할 수 있습니다.

코드는 다음과 같습니다.

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

#define SIZE 1000
int x[SIZE];
void init() {
  int i;
  for (i = 0; i < SIZE; i++) x[i] = rand() % 1000;
}
void* print(void* a) {
  int i;
  for (i = 0; i < SIZE; i++) printf("%d ", x[i]);
  printf("\n");
}
void* sort(void* b) {
  int i, j, temp;
  for (i = 0; i < SIZE - 1; i++)
    for (j = SIZE - 1; j >= 1; j--)
      if (x[j] < x[j - 1]) {
        temp = x[j];
        x[j] = x[j - 1];
        x[j - 1] = temp;
      }
}
int main() {
  srand(time(0));
  init();
  pthread_t tSort, tPrint;
  pthread_create(&tSort, NULL, sort, NULL);
  pthread_create(&tPrint, NULL, print, NULL);
  pthread_join(tSort, NULL);
  pthread_join(tPrint, NULL);
  return 0;
}

이 코드에서 정렬인쇄 기능은 병렬로 실행됩니다. 그러나 출력이 표시되면 올바르지 않습니다.

그 이유는 정렬 스레드가 인쇄 스레드보다 느리고 동기화가 이루어지지 않기 때문입니다. 따라서 인쇄 스레드는 아직 정렬되지 않은 값을 빠르게 인쇄합니다. 따라서 출력이 정렬되지 않습니다.

다중 스레드 프로그램은 이러한 동기화 문제 및 기타 문제가 발생하기 쉽습니다. 다음으로 경쟁 조건이라는 문제에 대해 논의할 것입니다.

레이스 컨디션이란?

경쟁 조건은 여러 프로세스 또는 스레드가 일부 공유 데이터에 동시에 액세스할 때 발생할 수 있는 원하지 않는 조건입니다.

최종 값은 마지막으로 데이터에 액세스하는 프로세스/스레드에 따라 다릅니다. 예를 들어 설명하겠습니다.

AB라는 두 개의 스레드와 공유 변수 x가 있다고 가정합니다. 두 스레드 모두 명령 x++을 병렬로 실행하고 있습니다.

이 명령어를 저수준 언어로 비판적으로 살펴보면 이 명령어는 3개의 명령어로 구성되어 있다. 세 가지 지침은 다음과 같습니다.

read x inc x write x

첫 번째 명령은 메모리에서 레지스터(프로세서 내부)로 변수를 읽습니다. 두 번째 명령에서 변수는 1씩 증가하고 결과는 레지스터에 있습니다.

변수는 세 번째 및 마지막 명령에서 메모리에 다시 기록됩니다.

두 스레드가 이러한 명령을 병렬로 실행한다고 가정합니다. 실행 순서에 대해서는 전혀 모릅니다. 스케줄링 알고리즘, 타임 슬라이스, 프로세스의 우선 순위, 실행/새 프로세스 집합 등과 같은 다양한 요소에 따라 달라집니다.

이러한 명령을 실행하는 여러 가능한 시퀀스가 있을 수 있으며 매번 다른 시퀀스가 발생할 수 있습니다. 예를 들어 스레드 A는 처음 두 개의 명령을 실행할 수 있으며 운영 체제는 컨텍스트를 전환합니다.

변수의 값이 5이고 스레드 A5를 읽고 이를 1씩 증가시켰으므로 이제 값은 6입니다(단, 레지스터 내부). 컨텍스트가 전환되고 스레드 B가 실행을 시작하면 동일한 값인 5도 읽습니다.

스레드 B는 값을 1씩 증가시킵니다. 값은 6이 됩니다.

스레드 B는 마지막 명령을 완료하고 6 값을 메모리에 씁니다.

컨텍스트가 다시 전환되고 제어는 결국 스레드 A로 이동합니다. 이제 스레드 A는 마지막 명령인 x를 실행할 것입니다.

스레드 A가 값 5를 읽고 이를 1씩 증가시킨다는 점을 상기하십시오. 스레드 A6을 메모리에 씁니다.

결과적으로 값은 두 개의 스레드가 증가 작업을 수행했기 때문에 7이 아닌 6이 되며 값은 7이어야 합니다. 따라서 예기치 않은 컨텍스트 전환으로 인해 결과가 올바르지 않습니다.

생산자-소비자 문제

Producer-Consumer 문제를 논의하기 전에 다음 코딩 예제를 보자.

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

int count = 0;

void* producer(void* arg) {
  for (int i = 0; i < 50000; i++) count++;
}
void* consumer(void* arg) {
  for (int i = 0; i < 50000; i++) count--;
}
int main() {
  pthread_t tid1, tid2;
  printf("Count = %d\n", count);
  pthread_create(&tid1, NULL, producer, NULL);
  pthread_create(&tid2, NULL, consumer, NULL);
  pthread_join(tid1, NULL);
  pthread_join(tid2, NULL);
  printf("Count = %d\n", count);
  return 0;
}

이 코드는 생산자-소비자 문제라고 하는 고전적인 운영 체제 문제를 보여줍니다. 이 코드에는 0으로 초기화된 전역(공유) 변수 count가 있습니다.

producer 함수는 카운트를 1씩 증가시키고 consumer 기능은 카운트를 1씩 감소시킵니다.

그러나 두 함수는 컨텍스트 전환이 언제든지 발생할 수 있는 서로 다른 두 스레드에 의해 실행됩니다. 코드를 실행하면 쉽게 경험할 수 있습니다.

경합 조건이 없으면 두 함수 모두 동일한 횟수만큼 증가 및 감소하므로 결과는 0이어야 합니다. 단위 증가 및 감소는 count 값이 끝에 0이 되어야 함을 의미합니다.

그러나 이 코드를 실행하면 값이 0이 아님을 알 수 있습니다. 오히려 각 반복마다 다른 값이 나타나 스케줄링을 예측할 수 없음을 분명히 보여줍니다.

몇 가지 실용적인 생산자-소비자 예

위에서 논의한 두 스레드의 증분 예제는 가상의 문제가 아닙니다. 멀티스레딩으로 작업할 때 이러한 많은 실제 시나리오가 존재합니다.

예를 들어 은행 시스템에서 고객은 금액을 인출하기 위해 두 가지 선택을 할 수 있습니다. 하나는 은행에 수표를 제시하는 것이고, 동시에 누군가가 고객의 ATM 카드를 사용하여 ATM에서 금액을 인출할 수 있습니다.

마찬가지로 두 명 이상의 교사가 우연히 동일한 학생의 점수를 업데이트/게시하여 학생의 총점을 높이거나 낮출 수 있습니다. 둘 이상의 회사가 동시에 고객의 주간, 월간 또는 연간 구독을 공제할 수 있습니다.

경합 상태로 인해 잘못된 결과가 발생할 수 있으며 이는 적절하지 않습니다. 따라서 경쟁 조건 문제로 인해 멀티 스레딩의 적용 가능성이 많은 시나리오에서 실용적이지 않다고 발표해야 합니까?

아니요, 경합 상태를 방지/제어할 수 있는 솔루션이 있습니다.

경쟁 조건 방지

경쟁 조건을 위한 하드웨어 및 소프트웨어 솔루션이 모두 있습니다. 하드웨어 솔루션은 관련 원자 명령어를 CPU 명령어 세트의 일부로 제공하는 것입니다(예: x86 아키텍처의 CMPXCHG).

소프트웨어 솔루션은 뮤텍스 또는 세마포어를 사용하여 중요한 섹션에 대한 프로세스 항목을 정규화합니다.

원자적 작동

제조업체는 하드웨어에서 원자적 작업을 구현할 수 있습니다. 하드웨어에서 원자 연산을 구현한 후 이러한 명령은 컨텍스트 전환 없이 실행을 완료합니다. 즉, 프로세스/스레드 실행을 부분으로 나누어서는 안 됩니다.

그러나 이 솔루션은 제조업체용이며 일반 사용자가 구현할 수 없습니다.

상호 배제

상호 배제는 하나의 프로세스/스레드가 일부 공유 변수에 액세스하는 경우 다른 프로세스/스레드가 다른 프로세스/스레드가 이미 보유하고 있는 공유 변수에 액세스하도록 허용되지 않아야 함을 의미합니다.

상호 배제는 뮤텍스(잠금) 또는 세마포어를 사용하여 구현할 수 있습니다. 개념을 명확히 하기 위해 클릭하여 Mutex 대 Semaphore에 대해 읽을 수 있습니다.

Mutex를 사용하여 경쟁 조건 방지

Mutex는 특수 잠금의 개념입니다. 이 잠금은 스레드 간에 공유됩니다. 그러나 여러 스레드가 뮤텍스를 잠그려는 경우 하나의(첫 번째) 스레드만 잠금에 성공하고 임계 영역에 들어갑니다.

뮤텍스를 잠그려는 나머지 스레드는 첫 번째 스레드가 임계 구역에서 나와 뮤텍스를 잠금 해제할 때까지 차단됩니다.

잠금 해제된 뮤텍스는 언제든지 한 스레드에 의해 잠길 수 있습니다. 뮤텍스의 잠금을 해제하면 차단 스레드 중 하나가 뮤텍스를 잠그고 임계 영역에 들어갈 수 있는 기회를 얻을 수 있습니다.

뮤텍스에는 대기열 개념이 없습니다. 모든 차단/대기 스레드는 뮤텍스를 가져오고 잠글 기회를 얻을 수 있습니다.

경쟁 조건에 두 개의 스레드가 있거나 경쟁 조건에 여러 스레드가 있고 회전/대기열 문제가 없을 때 매우 간단한 솔루션이며 매우 유용합니다.

pthread Linux 라이브러리를 사용하여 뮤텍스 잠금을 구현하는 다음 C 예제를 사용하여 솔루션을 실현해 봅시다.

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

int count = 0;
pthread_mutex_t lock;

void* producer(void* arg) {
  for (int i = 0; i < 50000; i++) {
    pthread_mutex_lock(&lock);
    count++;
    pthread_mutex_unlock(&lock);
  }
}
void* consumer(void* arg) {
  for (int i = 0; i < 50000; i++) {
    pthread_mutex_lock(&lock);
    count--;
    pthread_mutex_unlock(&lock);
  }
}
int main() {
  pthread_t tid1, tid2;
  printf("Count = %d\n", count);
  pthread_mutex_init(&lock, NULL);
  pthread_create(&tid1, NULL, producer, NULL);
  pthread_create(&tid2, NULL, consumer, NULL);
  pthread_join(tid1, NULL);
  pthread_join(tid2, NULL);
  pthread_mutex_destroy(&lock);
  printf("Count = %d\n", count);
  return 0;
}

이 코드를 여러 번 실행할 수 있습니다. 매번 카운트 0의 값을 제공합니다.

코드에는 pthread_mutex_t라는 특수 유형의 공유 변수 잠금이 있습니다. 생산자 및 소비자 기능은 중요한 섹션 명령인 count++count--를 실행하기 전에 잠깁니다.

다시 말하지만 둘 다 중요 섹션이 끝난 후 잠금 해제 함수를 호출하여 뮤텍스를 해제합니다.

main 함수에서 뮤텍스를 초기화하고 뮤텍스 사용 후 뮤텍스를 파괴합니다.

세마포어를 사용하여 경쟁 조건 방지

세마포어는 운영 체제에서 제공하는 솔루션입니다. 세마포어는 스레드 동기화를 위한 솔루션입니다.

특정 명령 실행 순서를 원할 수 있습니다. 그러나 경쟁 조건을 피하기 위해 사용할 수 있지만 정확한 해결책은 아닙니다. 나중에 그 이유에 대해 논의할 것입니다.

여기에서 우리는 동기화라는 길고 자세한 주제를 피하고 경쟁 조건을 피하기 위해 세마포어를 사용하는 방향으로 나아갑니다. 세마포어를 사용하는 것은 특정 지점에서 스레드를 중지하고 다른 스레드의 신호를 기다리는 것입니다.

우리는 세마포어를 0으로 초기화하고 대기 함수를 호출하여 세마포어를 1로 줄이고 절전 상태로 들어갑니다.

나중에 다른 스레드가 신호 작업을 호출하여 세마포어를 1로 증가시키고 큐에서 잠자고 있는 일부 스레드를 깨웁니다.

Linux에서 세마포어는 semaphore.h 라이브러리에서 사용할 수 있습니다. 대기 및 신호 작업을 위한 대기 및 사후 기능이 있습니다.

코드를 참조하십시오:

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

int count = 0;
sem_t semaphore1, semaphore2;

void* producer(void* arg) {
  for (int i = 0; i < 50000; i++) {
    count++;
    sem_post(&semaphore1);
    sem_wait(&semaphore2);
  }
}
void* consumer(void* arg) {
  for (int i = 0; i < 50000; i++) {
    sem_wait(&semaphore1);
    count--;
    sem_post(&semaphore2);
  }
}
int main() {
  pthread_t tid1, tid2;
  printf("Count = %d\n", count);
  sem_init(&semaphore1, 0, 0);
  sem_init(&semaphore1, 0, 0);
  pthread_create(&tid1, NULL, producer, NULL);
  pthread_create(&tid2, NULL, consumer, NULL);
  pthread_join(tid1, NULL);
  pthread_join(tid2, NULL);
  sem_destroy(&semaphore1);
  sem_destroy(&semaphore2);
  printf("Count = %d\n", count);
  return 0;
}

다시 말하지만, 이 코드를 실행하고 공유 변수의 정확한/올바른 값을 얻을 수 있습니다. 구현은 뮤텍스와 유사합니다. 그러나 한 가지 주요 차이점은 초기화 단계입니다.

초기화 함수에는 세 가지 매개변수가 있습니다. 첫 번째는 세마포어 변수입니다.

두 번째는 0 또는 1입니다. 여기서 0은 세마포어가 동일한 프로세스의 스레드 간에 공유됨을 의미하고 1은 세마포어가 서로 다른 프로세스의 스레드 간에 공유됨을 의미합니다.

세 번째 매개변수는 세마포어의 값입니다. 여러 스레드(일부 개수로 제한됨)가 함께 크리티컬 섹션에 들어갈 수 있도록 하려면 개수로 세마포어 값을 초기화할 수 있습니다.

이 구현의 두 번째 차이점은 두 개의 세마포어를 사용하는 반면 뮤텍스의 경우 하나의 잠금만 사용한다는 것입니다.

뮤텍스 코드는 공유 변수를 사용하기 전에 잠금을 설정하고 공유 변수를 사용한 후에 잠금을 해제합니다. 고객이 은행 라커룸에서 라커를 조작하려고 할 때마다 직원이 고객에게 열쇠를 주어 라커를 잠그기 전에 라커를 열고 라커가 잠긴 후에 라커룸을 엽니다.

그러나 세마포어에서는 두 개의 세마포어를 사용합니다. 둘 다 0으로 초기화됩니다. 즉, 다른 스레드가 신호를 보낼 때까지 호출 스레드 대기가 차단됩니다. 여기에서 소비자는 카운트를 증가시킨 후 생산자 스레드가 신호를 보내는 세마포어 1에서 대기합니다.

동시에 생산자 스레드는 카운트를 감소시킨 후 소비자 스레드가 신호를 보내는 세마포어 2에서 대기합니다.

이렇게 하면 생산자와 소비자가 카운트를 하나씩 늘리고 줄이는 것이 동기화입니다. 세마포어를 사용하여 카운트를 정확하게 유지합니다. 그러나 스레드가 순서대로 실행되도록 제한합니다.

뮤텍스의 경우 두 스레드는 공유 변수 수의 값을 손상시키지 않고 임의의 순서로 실행할 수 있습니다.

결론

스레드는 코드/함수를 병렬로 실행하는 데 매우 유용합니다. 그러나 경쟁 조건과 같은 스레드에는 몇 가지 문제가 있습니다. 조건으로 인해 예기치 않은 결과가 발생할 수 있습니다.

운 좋게도 경쟁 조건을 피하기 위해 뮤텍스 잠금과 세마포어를 사용할 수 있습니다. 경쟁 조건을 처리하는 최선의 올바른 방법은 뮤텍스입니다.