본문 바로가기
CS 지식/시스템 프로그래밍

[C언어] Signal 프로그래밍에서의 Race Condition과 해결법

by 코딩하는 동현😎 2024. 10. 17.

시그널 프로그래밍에서의 Race Condition과 해결법

시그널은 비동기적으로 동작하기 때문에, 잘못 설계된 시그널 핸들러는 Race Condition을 유발할 수 있다. Race Condition은 여러 흐름(프로세스나 스레드)이 공유 자원에 동시 접근하면서 예상치 못한 동작을 초래하는 문제를 말한다. 본 글에서는 시그널 핸들러로 인해 발생할 수 있는 Race Condition의 원리, 해결 방법, 그리고 관련 코드를 중심으로 자세히 설명한다.


Race Condition의 발생 관련 개념

Race Condition은 다수의 흐름(프로세스나 스레드)이 공유 자원에 동시에 접근하거나 수정하려고 할 때 발생할 수 있는 문제를 말한다. 이를 방지하려면 공유 자원에 대한 Critical Section(임계영역)을 보호해야 한다.

Critical Section이란 무엇인가?

Critical Section은 여러 흐름(프로세스 또는 스레드)이 동시에 접근하면 안 되는 공유 자원에 접근하는 코드 블록이다. 예를 들어, 작업 리스트를 추가하거나 삭제하는 코드는 Critical Section에 해당한다. Critical Section이 적절히 보호되지 않으면 상호 배제(Mutual Exclusion)가 깨져 데이터 무결성이 손상될 수 있다.

Critical Section과 상호 배제

상호 배제는 Critical Section이 한 번에 하나의 흐름에 의해서만 실행되도록 보장하는 원칙이다. 이를 통해 다음과 같은 문제를 방지한다:

  • 데이터 무결성 손상: 여러 흐름이 동시에 공유 자원에 접근하여 예상치 못한 결과를 초래하는 문제.
  • 충돌 및 중단: 동일한 데이터 구조를 수정하는 도중 다른 흐름이 개입해 충돌이 발생.

Atomicity의 중요성

Atomicity는 연산이 쪼개지지 않고 완전하게 실행되거나 실행되지 않는 상태를 보장한다. Critical Section 내의 작업이 원자적으로 실행되지 않으면, 작업 도중 중단되거나 다른 흐름이 개입해 Race Condition이 발생할 수 있다. 원자성을 보장하지 않으면 다음과 같은 상황이 생길 수 있다:

  • 작업 리스트에서 항목을 삭제하는 도중 다른 흐름이 동일 항목을 다시 추가하거나 수정.
  • 데이터 불일치로 인해 프로그램의 예상치 못한 종료 또는 비정상 동작.

Race Condition의 발생 원인

Signal Handlers as Concurrent Flows

시그널 핸들러는 프로세스의 메인 흐름과 독립적으로, 비동기적으로 실행된다. 이는 마치 두 개의 흐름(메인 프로그램과 시그널 핸들러)이 동시에 실행되는 것처럼 동작한다. 결과적으로, 두 흐름이 동일한 공유 자원에 접근할 경우 Race Condition이 발생할 수 있다.

Nested Signal Handlers

시그널 핸들러가 실행되는 동안 다른 시그널이 발생하여 중첩 호출될 수 있다. 예를 들어, SIGCHLD 핸들러가 실행 중일 때 또 다른 자식 프로세스가 종료되어 다시 SIGCHLD 핸들러가 호출되는 상황이다. 이러한 중첩은 데이터 손상 및 예기치 않은 동작을 유발할 수 있다.

Race Condition의 실질적 문제

공유 자원에 접근하는 코드(예: 작업 리스트 수정)는 Critical Section으로 간주된다. 이 영역이 보호되지 않으면 다음과 같은 문제가 발생할 수 있다:

  1. 순서 문제: 한 흐름이 작업을 완료하기 전에 다른 흐름이 개입하여 데이터가 손상된다.
  2. 데이터 일관성 문제: 예상치 못한 순서로 작업이 실행되어 결과 데이터가 불일치.
  3. Deadlock: 상호 배제를 위해 Lock을 사용할 때 잘못된 설계로 인해 두 흐름이 무한 대기 상태에 빠질 수 있다.

Race Condition 해결 전략

1. 시그널 블로킹과 마스킹

시그널 발생을 일시적으로 차단하여 특정 코드 블록(Critical Section)이 안전하게 실행되도록 한다. POSIX 표준의 sigprocmask()sigsuspend() 함수를 사용하여 이를 구현할 수 있다.

2. Mutual Exclusion 보장

Critical Section이 실행 중일 때 다른 흐름이 동일한 공유 자원에 접근하지 못하도록 상호 배제를 보장한다.

3. Atomicity 보장

Critical Section 내부 연산은 원자적으로 실행되어야 한다. 이를 통해 연산 도중에 중단되거나 다른 연산이 끼어들지 않도록 한다.

4. Signal Safe Functions 사용

핸들러에서는 Async-Signal-Safe 함수만 호출해야 한다. 예를 들어, printf()malloc()은 사용하지 않고, 대신 write()를 사용한다.

5. sigset_t와 sigprocmask 활용

POSIX에서는 시그널 블로킹과 관련하여 sigset_t 구조체와 sigprocmask 함수를 제공한다. sigset_t는 시그널 집합을 나타내며, 특정 시그널을 추가, 삭제하거나 초기화할 수 있다.

  • sigemptyset(&set): 빈 시그널 집합 생성.
  • sigfillset(&set): 모든 시그널 추가.
  • sigaddset(&set, SIGINT): 특정 시그널 추가.
  • sigdelset(&set, SIGINT): 특정 시그널 제거.
  • sigprocmask(SIG_BLOCK, &set, &oldset): 특정 시그널 차단.

Race Condition 문제 코드

아래는 시그널 핸들러가 적절히 처리되지 않아 Race Condition이 발생할 수 있는 코드이다:

#define MAXJOBS 10

// Job 구조체
typedef struct {
    pid_t pid;
    int active;
} job_t;

job_t jobs[MAXJOBS];
int job_count = 0;

void initjobs();
void addjob(pid_t pid);
void deletejob(pid_t pid);

void chldHandler(int sig) {
    pid_t pid;
    while ((pid = waitpid(-1, NULL, 0)) > 0) {
        deletejob(pid); // Critical Section
    }
}

int main() {
    pid_t pid;
    signal(SIGCHLD, chldHandler);
    initjobs();

    while (job_count < MAXJOBS) {
        if ((pid = fork()) == 0) {
            execlp("/bin/date", "date", NULL);
            exit(1);
        }
        addjob(pid); // Critical Section
    }

    while (1);
}

문제점

  • 자식 프로세스가 종료되면 SIGCHLD 시그널이 발생하여 chldHandler가 호출된다.
  • addjob()이 실행되기 전에 deletejob()이 실행되면, 작업 리스트에서 존재하지 않는 pid를 삭제하려 시도하여 오류가 발생할 수 있다.

Race Condition 해결 코드

아래 코드는 시그널 블로킹을 사용하여 Race Condition을 방지한다:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

#define MAXJOBS 10

typedef struct {
    pid_t pid;
    int active;
} job_t;

job_t jobs[MAXJOBS];
int job_count = 0;

void initjobs();
void addjob(pid_t pid);
void deletejob(pid_t pid);

void chldHandler(int sig) {
    pid_t pid;
    sigset_t allmask, prev;
    sigfillset(&allmask);

    while ((pid = waitpid(-1, NULL, 0)) > 0) {
        sigprocmask(SIG_BLOCK, &allmask, &prev);
        deletejob(pid); // Critical Section
        sigprocmask(SIG_SETMASK, &prev, NULL);
    }
}

int main() {
    pid_t pid;
    sigset_t mask, prev;

    sigemptyset(&mask);
    sigaddset(&mask, SIGCHLD);
    signal(SIGCHLD, chldHandler);
    initjobs();

    while (job_count < MAXJOBS) {
        sigprocmask(SIG_BLOCK, &mask, &prev);
        if ((pid = fork()) == 0) {
            sigprocmask(SIG_SETMASK, &prev, NULL);
            execlp("/bin/date", "date", NULL);
            exit(1);
        }
        addjob(pid); // Critical Section
        sigprocmask(SIG_SETMASK, &prev, NULL);
    }

    while (1);
}

개선점

  1. sigprocmask() 사용:
    • SIGCHLD 시그널을 차단하여 Critical Section의 안전성을 보장한다.
  2. 핸들러 내 신호 차단:
    • deletejob()이 실행 중일 때 다른 시그널이 처리되지 않도록 모든 시그널을 차단한다.
  3. 일관성 유지:
    • 자식 프로세스 생성과 작업 리스트 추가(addjob) 간의 상태 불일치를 방지한다.

주요 함수 설명

sigprocmask(int how, const sigset_t *set, sigset_t *oldset)

  • 역할: 현재 시그널 마스크를 설정하거나 복원한다.
  • 인수:
    • how: 설정 방식 (SIG_BLOCK, SIG_UNBLOCK, SIG_SETMASK)
    • set: 새로 설정할 시그널 집합
    • oldset: 이전 상태를 저장할 시그널 집합

sigsuspend(const sigset_t *mask)

  • 역할: 지정된 시그널 마스크를 설정한 후 시그널 대기 상태에 진입한다.
  • 특징:
    • 시그널이 처리된 후 항상 -1을 반환하며, 기존 마스크로 복원된다.

Signal Safe Functions와 핸들러 설계 원칙

시그널 핸들러는 메인 프로그램과 동시에 실행되므로 주의가 필요하다:

  1. 핸들러는 간단하게 유지:
    • 전역 플래그를 설정하고 빠르게 반환하도록 설계한다.
  2. Async-Signal-Safe 함수만 호출:
    • printf()malloc() 대신 write()와 같은 안전한 함수를 사용한다.
  3. errno 저장 및 복원:
    • 핸들러 시작 시 errno를 저장하고, 종료 시 복원한다.
  4. Critical Section 보호:
    • 공유 데이터 구조에 접근할 때 모든 시그널을 일시적으로 차단한다.
  5. 전역 변수는 volatile로 선언:
    • 컴파일러가 최적화를 통해 변수 상태를 레지스터에 저장하지 않도록 한다.
  6. 플래그는 volatile sig_atomic_t로 선언:
    • 플래그를 읽거나 쓰기만 하고, 복잡한 연산(예: flag++)은 사용하지 않는다.

결론

시그널 프로그래밍에서 Race Condition은 데이터 무결성과 프로그램 안정성을 위협한다. 이를 해결하려면 시그널 블로킹, 상호 배제, 원자성 보장과 같은 동기화 기법을 활용해야 한다. 특히, sigprocmask()와 같은 POSIX 표준 도구는 Critical Section의 안전성을 보장하는 강력한 수단이다. 시그널 핸들러 설계 시 이러한 원칙을 준수하면 안정적이고 신뢰할 수 있는 시스템을 구현할 수 있다.

반응형

댓글