Linux System V 공유 메모리 및 세마포어를 활용한 프로세스 상호 배제 구현 상세 기록

프로젝트 요구사항

다음 세 가지 프로그램을 작성하여 Linux System V IPC(프로세스 간 통신) 메커니즘을 통한 공유 메모리 접근의 상호 배제를 구현합니다.

  1. 프로세스 A (생성자): 공유 메모리 및 세마포어를 유일하게 생성하고, 공유 메모리에 초기 문자열을 저장합니다. 세마포어는 이진 세마포어로 초기값 1(뮤텍스 잠금)을 설정합니다. 테스트가 완료될 때까지 블록 상태로 대기한 후 커널 자원을 제거합니다.
  2. 프로세스 B 및 프로세스 C (소비자): 두 프로그램은 완전히 동일한 코드를 가집니다. 이미 생성된 공유 메모리와 세마포어를 열어, 세마포어를 사용하여 상호 배제를 구현하고 공유 메모리의 내용을 읽습니다.
  3. 목표: 임계 구역(공유 메모리)에는 동시에 하나의 프로세스만 접근하도록 하여 동시성 경쟁 문제를 방지합니다.

System V IPC 기본 원칙

1. IPC 자원 관리 규칙

  • shmget / semget 호출 시 IPC_CREAT | IPC_EXCL 플래그 사용: 자원이 존재하지 않으면 새로 생성하고, 이미 존재하면 오류를 반환합니다. 이는 주로 자원을 최초로 생성하는 프로세스 A에 사용됩니다.
  • shmget / semget 호출 시 권한만 전달 (예: 0644): 이미 존재하는 자원을 엽니다. 자원이 존재하지 않으면 오류를 반환합니다. 이는 기존 자원을 사용하는 프로세스 B/C에 사용됩니다.
  • 세마포어 초기값을 1로 설정: 이진 세마포어로 동작하여 상호 배제를 구현합니다.
    • acquire_semaphore (P 연산): semop(semId, {0,-1,0})를 호출하여 자원을 점유합니다.
    • release_semaphore (V 연산): semop(semId, {0,1,0})를 호출하여 자원을 해제합니다.
  • 공유 메모리: shmat 함수로 프로세스의 가상 주소 공간에 연결(attach)하고, 사용 후에는 shmdt 함수로 분리(detach)합니다. 커널 자원(공유 메모리 및 세마포어)은 IPC_RMID를 통해 수동으로 제거해야 합니다.

2. 주의해야 할 핵심 사항 (주요 문제 발생 지점)

System V 세마포어는 프로세스가 종료되어도 잠금을 자동으로 해제하지 않습니다!

만약 V 연산이 실패하거나, 프로그램이 비정상적으로 종료되어 V 연산이 호출되지 않으면, 세마포어 값은 0으로 유지됩니다. 이 경우 이후 모든 프로세스의 P 연산은 영구적으로 블록되어 시스템이 멈출 수 있습니다.

전체 구현 코드

프로세스 A (생성자: creator.c)


#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>

#define SHARED_MEM_KEY_ID 80
#define SEMAPHORE_KEY_ID  20
#define SHARED_MEM_SIZE   256

// 세마포어 초기화에 사용되는 공용체
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
};

// P 연산: 잠금 (acquire)
void acquire_semaphore(int semId)
{
    struct sembuf operation = {0, -1, 0}; // 세마포어 값 1 감소
    if (semop(semId, &operation, 1) == -1) {
        perror("세마포어 잠금 실패");
    }
}

// V 연산: 잠금 해제 (release)
void release_semaphore(int semId)
{
    struct sembuf operation = {0, 1, 0}; // 세마포어 값 1 증가
    if (semop(semId, &operation, 1) == -1) {
        perror("세마포어 해제 실패");
    }
}

int main()
{
    key_t sharedMemKey = ftok(".", SHARED_MEM_KEY_ID);
    key_t semaphoreKey = ftok(".", SEMAPHORE_KEY_ID);

    // 1. 공유 메모리 생성 또는 가져오기
    int sharedMemId = shmget(sharedMemKey, SHARED_MEM_SIZE, IPC_CREAT | IPC_EXCL | 0644);
    if (sharedMemId == -1)
    {
        if (errno == EEXIST) {
            fprintf(stderr, "공유 메모리가 이미 존재합니다. 기존 자원을 가져옵니다.\n");
            sharedMemId = shmget(sharedMemKey, SHARED_MEM_SIZE, 0644);
            if (sharedMemId == -1) {
                perror("기존 공유 메모리 가져오기 실패");
                return 1;
            }
        } else {
            fprintf(stderr, "shmget 오류, errno:%d, %s\n", errno, strerror(errno));
            return 1;
        }
    }
    printf("【생성자】공유 메모리 생성/가져오기 성공 (ID: %d)\n", sharedMemId);

    // 2. 세마포어 생성 또는 가져오기 및 초기화
    int semaphoreId = semget(semaphoreKey, 1, IPC_CREAT | IPC_EXCL | 0644);
    union semun arg;
    arg.val = 1; // 이진 세마포어 초기값 설정
    
    if (semaphoreId == -1)
    {
        if (errno == EEXIST) {
            fprintf(stderr, "세마포어가 이미 존재합니다. 기존 자원을 가져옵니다.\n");
            semaphoreId = semget(semaphoreKey, 1, 0644);
            if (semaphoreId == -1) {
                perror("기존 세마포어 가져오기 실패");
                return 1;
            }
            // 기존 세마포어의 값은 재설정하지 않음!
        } else {
            fprintf(stderr, "semget 오류, errno:%d, %s\n", errno, strerror(errno));
            return 1;
        }
    }
    else
    {
        // 새로 생성된 세마포어에만 초기값을 설정
        if (semctl(semaphoreId, 0, SETVAL, arg) == -1) {
            perror("세마포어 초기값 설정 실패");
            return 1;
        }
        printf("【생성자】새 세마포어 생성 및 초기값 1 설정 (ID: %d)\n", semaphoreId);
    }

    // 3. 공유 메모리 연결
    char *sharedBuffer = (char *)shmat(sharedMemId, NULL, 0);
    if (sharedBuffer == (void *)-1)
    {
        perror("공유 메모리 연결 실패");
        return 1;
    }

    // 잠금 후 임계 자원 초기화
    acquire_semaphore(semaphoreId);
    strncpy(sharedBuffer, "초기화된 공유 데이터", SHARED_MEM_SIZE - 1); // 버퍼 오버플로우 방지
    sharedBuffer[SHARED_MEM_SIZE - 1] = '\0'; // 안전을 위한 널 종료
    printf("【생성자】공유 메모리 초기화 완료: \"%s\"\n", sharedBuffer);
    release_semaphore(semaphoreId);

    // 공유 메모리 분리
    if (shmdt(sharedBuffer) == -1) {
        perror("공유 메모리 분리 실패");
        return 1;
    }
    
    printf("【생성자】로컬 매핑이 분리되었습니다. 새 터미널에서 './consumer_A' 및 './consumer_B'를 실행하세요.\n");
    printf("모든 테스트가 완료되면 엔터 키를 눌러 커널 자원을 제거하세요.\n");
    getchar(); // 사용자 입력 대기

    // IPC 자원 제거
    if (semctl(semaphoreId, 0, IPC_RMID) == -1) {
        perror("세마포어 제거 실패");
        return 1;
    }
    if (shmctl(sharedMemId, IPC_RMID, NULL) == -1) {
        perror("공유 메모리 제거 실패");
        return 1;
    }
    printf("【생성자】모든 자원 제거 완료. 프로그램 종료.\n");
    return 0;
}

프로세스 B / 프로세스 C (소비자: consumer.c, 두 파일 동일)


#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>

#define SHARED_MEM_KEY_ID 80
#define SEMAPHORE_KEY_ID  20
#define SHARED_MEM_SIZE   256 // Note: SHM_SIZE is needed to calculate key, but not for accessing already existing shm.

// P 연산: 잠금 (acquire)
void acquire_semaphore(int semId)
{
    struct sembuf operation = {0, -1, 0};
    if (semop(semId, &operation, 1) == -1) {
        perror("세마포어 잠금 실패");
    }
}

// V 연산: 잠금 해제 (release)
void release_semaphore(int semId)
{
    struct sembuf operation = {0, 1, 0};
    if (semop(semId, &operation, 1) == -1) {
        perror("세마포어 해제 실패");
    }
}

int main()
{
    key_t sharedMemKey = ftok(".", SHARED_MEM_KEY_ID);
    key_t semaphoreKey = ftok(".", SEMAPHORE_KEY_ID);

    // 공유 메모리 가져오기
    int sharedMemId = shmget(sharedMemKey, 0, 0644); // 크기는 0으로 지정해도 기존 메모리 접근 가능
    if (sharedMemId == -1)
    {
        fprintf(stderr, "shmget 오류: 공유 메모리를 찾을 수 없습니다. errno:%d, %s\n", errno, strerror(errno));
        return 1;
    }

    // 세마포어 가져오기
    int semaphoreId = semget(semaphoreKey, 0, 0644); // 세마포어 개수는 0으로 지정해도 기존 세마포어 접근 가능
    if (semaphoreId == -1)
    {
        fprintf(stderr, "semget 오류: 세마포어를 찾을 수 없습니다. errno:%d, %s\n", errno, strerror(errno));
        return 1;
    }

    // 공유 메모리 연결
    char *sharedBuffer = (char *)shmat(sharedMemId, NULL, 0);
    if (sharedBuffer == (void *)-1)
    {
        perror("공유 메모리 연결 실패");
        return 1;
    }

    // 임계 구역: 세마포어를 사용하여 상호 배제 접근
    acquire_semaphore(semaphoreId);
    printf("==== 현재 프로세스가 공유 메모리에 접근합니다 ====\n");
    printf("공유 메모리 내용: \"%s\"\n", sharedBuffer);
    printf("==== 접근 완료, 잠금 해제 ====\n");
    release_semaphore(semaphoreId);

    // 공유 메모리 분리
    if (shmdt(sharedBuffer) == -1) {
        perror("공유 메모리 분리 실패");
        return 1;
    }
    
    return 0;
}

컴파일 및 표준 실행 절차

1. 컴파일 명령


# 생성자 프로세스 컴파일
gcc creator.c -o creator

# 소비자 프로세스 A 컴파일
gcc consumer.c -o consumer_A

# 소비자 프로세스 B 컴파일
gcc consumer.c -o consumer_B

2. 올바른 실행 순서

  1. 첫 번째 터미널에서 ./creator 실행. 프로그램이 사용자 입력 대기 상태가 됩니다. (종료하지 마세요)
  2. 새 터미널(두 번째)에서 ./consumer_A 실행. 공유 메모리 내용을 정상적으로 읽고 잠금을 해제합니다.
  3. 또 다른 새 터미널(세 번째)에서 ./consumer_B 실행. 이 또한 공유 메모리 내용을 정상적으로 읽고 잠금을 해제합니다.
  4. 모든 테스트가 완료되면, 첫 번째 터미널로 돌아가 엔터 키를 누르면 공유 메모리 및 세마포어 커널 자원이 자동으로 제거됩니다.

3. 잔여 IPC 자원 확인 및 제거 명령어


# 모든 세마포어 확인 (명령어와 옵션 사이에 공백 필수)
ipcs -s

# 모든 공유 메모리 확인
ipcs -m

# 강제로 멈춘 세마포어 제거 (ID는 'ipcs -s'로 확인된 ID로 대체)
ipcrm -s [세마포어_ID]

# 강제로 멈춘 공유 메모리 제거 (ID는 'ipcs -m'으로 확인된 ID로 대체)
ipcrm -m [공유_메모리_ID]

실제 문제 해결 및 팁 (주요 발생 문제 사례)

문제 1: 잠금 해제 함수에 잘못된 ID 전달로 인한 영구 데드락

잘못된 코드 예시:


release_semaphore(sharedMemId); // sharedMemId는 공유 메모리 ID입니다.

문제 분석: release_semaphore 함수 내부에서 호출되는 semop의 첫 번째 인자는 반드시 세마포어 ID(semaphoreId)여야 합니다. 유효하지 않은 ID를 전달하면 V 연산이 실패하고, 세마포어 값은 영원히 0으로 유지됩니다. 결과적으로 consumer_A 프로그램이 종료되어도 잠금이 해제되지 않아 이후 consumer_B 등이 acquire_semaphore에서 영구적으로 블록됩니다.

해결책:


release_semaphore(semaphoreId); // 올바른 세마포어 ID 전달

문제 2: printf("%s", &buffer[i]) 반복 출력 시 문자열 손상/중복

잘못된 코드 예시:


for(int i = 0; i < strlen(sharedBuffer); i++)
{
    printf("%s", &sharedBuffer[i]);
}

원리: %s 형식 지정자는 문자열의 시작 주소를 요구하며, 널 종료 문자(\0)를 만날 때까지 문자를 출력합니다. 위 코드에서는 i=0일 때 전체 문자열을 출력하고, i=1일 때는 두 번째 문자부터 다시 전체 문자열을 출력하는 식으로 반복되어 중복된 내용이 계속 출력됩니다. 또한 \0이 없는 메모리 영역에 접근하여 알 수 없는 문자나 충돌이 발생할 수 있습니다.

두 가지 해결책:

  1. 전체 문자열 직접 출력 (권장):
    
    printf("내용: %s\n", sharedBuffer);
            
  2. 문자 단위로 출력:
    
    for(int i = 0; i < strlen(sharedBuffer); i++)
    {
        printf("%c", sharedBuffer[i]);
    }
    printf("\n"); // 줄바꿈 추가
            

문제 3: 포인터에 sizeof(pointer)를 사용하여 공유 메모리 크기 계산 실패

잘못된 코드 예시:


char *sharedBuffer = shmat(...);
for(int i = 0; i < sizeof(sharedBuffer); i++)
    // ...

원리: sharedBufferchar* 타입의 포인터이므로, sizeof(sharedBuffer)는 포인터 자체의 크기(64비트 시스템에서는 대개 8바이트)를 반환합니다. 이 값은 실제 공유 메모리 버퍼의 크기(예: 256바이트)와는 전혀 관련이 없습니다. 따라서 위 루프는 8번만 실행되어 공유 메모리 전체에 접근하지 못합니다.

해결책:

  • 전체 메모리 버퍼를 순회해야 한다면, 정의된 상수 크기를 사용합니다: for(int i=0; i < SHARED_MEM_SIZE; i++)
  • 유효한 문자열 데이터만 순회해야 한다면, strlen()을 사용합니다: for(int i=0; i < strlen(sharedBuffer); i++)

문제 4: strlen 함수로 int 배열의 요소 개수 세기 불가능

원리: strlen 함수는 단일 바이트 널 종료 문자(\0)를 문자열의 끝으로 인식하고 그 이전까지의 길이를 반환합니다. 이 함수는 char 타입의 문자열에만 적용될 수 있습니다. int 배열의 숫자 0은 4바이트를 차지하며, strlen은 이를 문자열 종료로 인식하지 못하므로, int 배열의 유효한 요소 수를 세는 데 사용할 수 없습니다. 스택에 할당된 배열의 길이는 sizeof(arr)/sizeof(int)와 같이 계산하며, 포인터의 경우 별도의 길이 정보를 수동으로 관리해야 합니다.

문제 5: 명령어 오타 또는 구문 오류

  1. ipcs-s 명령 오류 (command not found): 명령어와 인자 사이에 공백이 필요합니다. ipcs -s
  2. ./consumer_a 실행 시 No such file: Linux는 파일 이름을 대소문자를 구분합니다. 컴파일 시 생성된 실행 파일 이름(예: consumer_A)과 정확히 일치시켜야 합니다.
  3. ipcs 도구 누락: Ubuntu와 같은 시스템에서는 sudo apt install ipcs 명령으로 설치해야 할 수 있습니다.

문제 6: 생성자 프로세스 A가 대기하지 않아 자원이 조기 파괴되는 문제

만약 프로세스 A에 getchar()와 같은 대기 메커니즘이 없다면, A는 자원 생성 직후 IPC_RMID를 호출하여 공유 메모리와 세마포어를 즉시 파괴합니다. 이 상태에서 프로세스 B/C를 실행하면, 필요한 자원을 찾지 못해 오류가 발생하거나 acquire_semaphore에서 영구적으로 블록될 수 있습니다.

해결책: 프로세스 A에 getchar()를 추가하여 사용자가 수동으로 테스트 완료를 알리고 자원을 해제하도록 합니다.

문제 7: 세마포어는 오직 생성 시에만 초기값을 설정해야 함

잘못된 초기화 로직 (매번 SETVAL 호출):


int semaphoreId = semget(..., IPC_CREAT|IPC_EXCL|0644);
if(semaphoreId == -1) {
    // 이미 존재, 가져오기
    semaphoreId = semget(..., 0644);
    // 이 경우에도 semctl(semaphoreId,0,SETVAL,arg); 호출
} else {
    semctl(semaphoreId,0,SETVAL,arg); // 새로 생성 시 값 1로 초기화
}

문제 분석: 만약 프로세스 A가 매번 실행될 때마다 semctl(..., SETVAL, arg)를 호출하여 세마포어 값을 강제로 1로 재설정한다면 문제가 발생합니다. 예를 들어, consumer_A가 잠금을 획득하여 세마포어 값이 0인 상태(임계 구역 접근 중)일 때, A가 다시 실행되어 세마포어 값을 강제로 1로 재설정하면, 여러 프로세스가 동시에 임계 구역에 접근하게 되어 상호 배제가 무효화됩니다.

해결책: IPC_EXCL 플래그를 사용하여 세마포어가 새로 생성될 때(즉, semget이 성공했을 때만) semctl을 통해 초기값을 설정해야 합니다. 기존 세마포어를 가져오는 경우에는 값을 변경하지 않아야 합니다. (현재 제공된 creator.c 코드는 이 원칙을 따르고 있습니다.)

문제 8: 불필요한 헤더 파일 및 오타

  • 반복적으로 포함된 헤더 파일 (예: <sys/ipc.h> 중복)이나 사용되지 않는 헤더 파일 (예: <sys/msg.h>, <sys/stat.h>)은 코드의 가독성을 해치고 불필요한 의존성을 만듭니다.
  • 오류 메시지나 변수명에 오타 (예: shmgeit 대신 shmget)는 디버깅을 어렵게 하고 코드의 품질을 떨어뜨립니다.

핵심 지식 요약

  1. 자원 권한 구분: 프로세스 A는 IPC_CREAT 플래그를 사용하여 자원을 생성하고, 프로세스 B/C는 단순히 기존 자원을 엽니다.
  2. 상호 배제 원리: 이진 세마포어를 사용하며, 초기값을 1로 설정하여 하나의 P 연산에 하나의 V 연산이 정확히 대응하도록 합니다.
  3. 세마포어의 비자동 해제: System V 세마포어는 프로세스 종료 시 잠금을 자동으로 해제하지 않습니다. 잘못된 사용은 영구적인 블록 상태를 초래할 수 있습니다.
  4. 포인터 크기 문제: sizeof(포인터)는 포인터 변수 자체의 크기를 반환하며, 가리키는 버퍼의 실제 크기가 아닙니다.
  5. strlen의 제한: strlen은 널 종료 문자(\0)로 끝나는 char 타입의 문자열에만 적용 가능합니다.
  6. System V IPC 자원 수동 관리: 공유 메모리 및 세마포어와 같은 커널 자원은 사용 후 반드시 IPC_RMID를 통해 수동으로 제거해야 합니다. ipcsipcrm 명령어를 사용하여 관리할 수 있습니다.

확장 질문

  1. 세마포어 없이 프로세스 B와 C를 동시에 실행하면 어떤 현상이 발생할까요? (힌트: 동시성 문제, 데이터 경쟁)
  2. 프로세스 B가 공유 메모리에 접근하는 도중 강제로 kill되면 세마포어가 자동으로 해제될까요? (힌트: 수동 정리의 필요성)
  3. 파이프(pipe)나 메시지 큐(message queue)를 사용하여 세마포어와 유사한 상호 배제 기능을 구현할 수 있을까요?

태그: Linux IPC System V IPC 공유 메모리 세마포어 상호 배제

9월 1일 07:28에 게시됨