본문 바로가기

학부 수업 정리/시스템프로그래밍 (21-2)

[시스템] 5. Race Condition & Signal Blocking

1. Race Condition - Simple 예제

시그널핸들러와 이 시그널을 호출하는 main() 함수에 둘 다 $\verb|*i = *i + value;|$ 코드가 있다고 생각하자. 이 코드를 어셈블리 코드로 나타내면 아래와 같다. (ecx, edx는 각 레지스터 이름에 해당하는 변수 이름을 임의로 지정하였다.)

my_sighanler:
    ecx = *i;           // movq (%rax), %ecx   #1
    ecx = ecx + value;  // addl %eax, %ecx     #2
    *i = ecx;           // movq %ecx, (%rax)   #3

main:
    edx = *i;           // movq (%rax), %edx   #4
    edx = edx + value;  // addl %eax, %edx     #5
    *i = edx;           // movq %edx, (%rax)   #6

main() 함수가 4, 5까지 실행되다가 시그널이 도착했다고 가정해보자. 4랑 5를 지나면 $\verb|value + *i|$ 값이 edx에 저장된 상태가 된다. 이 때 시그널이 도착하고 1부터 3까지의 과정을 거치면 %rax에는 $\verb|*i + value|$ 값이 저장된다. 시그널 핸들러가 끝나면 다시 main() 으로 돌아오는데, 이때 6번 과정을 통해 다시 %rax 공간에 $\verb|*i + value|$ 값이 저장된다. %edx 레지스터의 값이 시그널 핸들러에서 인식을 못해서 value가 2번 더해지지 않는 현상이 발생하는데, 이것이 Race Condition 이다.

 

2. Race Condition - job list 예제

int main(int argc, char** argv) {
    int pid;

     signal(SIGCHLD, handler);
     initjobs(); /* Initialize the job list */
     
     while (1) {
         if ((pid = fork()) == 0) { /* Child */
             execve("/bin/date", argv, NULL);
        }
        addjob(pid); /* Add child to job list (Linked List) */
     }
     exit(0);
}

void handler(int sig) {
    int olderrno = errno;
    pid_t pid;
    
    while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap child */
       deletejob(pid); /* Delete the child from the job list */
    }
    
    if (errno != ECHILD)
        sio_error("waitpid error");
    errno = olderrno;
}

시그널은 queueing 되지 않아 여러번 발생할 수 있으므로 while 반복문으로 첫 번째로 Reaping 해준 자식 프로세스 외에도 종료된 좀비 프로세스가 있는지 확인하고 Reaping 해준다. Reaping 할 때는 $\verb|deletejob(pid)|$를 통해 job list에 있던 프로세스를 삭제한다. 근데 이 함수가 실행되는 중에 main() 에서 $\verb|addjob(pid)|$를 통해 새로운 pid를 넣을려고 했을수도 있다. (Race Condition 발생) 그러면 포인터 충돌이 일어나서 연결 리스트의 구조가 깨지게 되고, 이후에도 계속 포인터가 꼬이는 오류가 일어날 수 있다.

  • Critical Section: $\verb|*i, joblist|$ 처럼 자원이 공유되는 영역을 의미한다. 이 영역은 반드시 동시에 실행되서는 안된다.
  • Mutual Exclusion: Critical Section 에 하나의 스레드만 실행되도록 보장하는 것을 의미한다. 예를 들어 job list 예제에서 $\verb|addjob, deletejob|$이 한쪽이 실행되면 리턴되기 전까지 다른 한쪽은 실행하지 않도록 하는 것이다.
  • Atomicity: Mutual Exclusion을 보장하면 해당 Critical Section은 Atomic 하게 실행된다고 이야기한다.

 

3. Blocking Signals

1. 자동으로 blocking하는 메커니즘: 어떤 시그널이 실행되는 중에 동일한 시그널이 계속 올 경우, Kernel이 내재적으로 시그널 핸들러를 실행하지 않는다.
2. 명시적으로 blocking과 unblocking하는 메커니즘: $\verb|sigprocmask / sigemptyset, sigfillset, sigaddset, sigdelset|$
$\verb|sigprocmask|$를 통해 Blocked 비트 벡터의 특정 비트들을 1로 마스킹하여 어떤 시그널에 대해서 내가 풀어주기 전까지 Block을 할 수 있다. 나머지도 비슷한 기능을 하는 함수들이다.

아래 코드는 $\verb|SIGINT|$가 발생하지 않도록 일시적으로 Blocking하는 예제이다. 

sigset_t mask, prev_mask;

sigemptyset(&mask);
sigaddset(&mask, SIGINT);

/* Block SIGINT and save previous blocked set */
sigprocmask(SIG_BLOCK, &mask, &prev_mask);

/* ... Code region that will not be interrupted by SIGINT */

/* Restore previous blocked set, unblocking SIGINT */
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
  • $\verb|sigset_t|$ 는 Blocked 비트 벡터 표현을 위한 자료형이고, $\verb|prev_mask|$는 원래 값을 저장해두는 용도이다.
  • $\verb|mask|$를 모두 0으로 채운 뒤, $\verb|mask|$에 $\verb|SIGINT|$에 해당하는 비트들이 1로 되도록 마스킹한다. 즉, $\verb|mask|$는 현재 $\verb|SIGINT|$가 발생하지 않도록 하는 도구이다.
  • 이제 $\verb|SIGINT|$가 발생하지 않도록 $\verb|sigprocmask|$를 이용하여 실제로 Blocking을 한다. 첫번째 인자로 BLOCK한다는 것을 명시하고, 두번째 인자의 $\verb|mask|$에 해당하는 시그널을 Blocking 한다. 세번째 인자는 Block 시키기 직전의 상태를 기록해두는 용도로 보낸다. 그럼 9번째 줄처럼 이후 코드에서는 $\verb|SIGINT|$가 Blocking 된다.
  • 다시 $\verb|SIGINT|$를 활성화시켜주기 위해 $\verb|sigprocmask|$를 이용하여 unblocking을 한다. 이때 $\verb|mask|$를 이용하여 환원하는 것이 아니라, $\verb|prev_mask|$를 이용해서  $\verb|sigprocmask|$를 호출하기 직전 기존 상태로 환원해야한다. (기존에 이미 Block된 상태에서 또 Block된 상태였을 경우, 바로 unblock해서는 안되기 때문. 호출되기 전 기존 상태로 되돌아간다는 개념이 적절하다.)

 

4. Race Condition 해결하기 - job list 예제

int main(int argc, char **argv)
{
    int pid;
    sigset_t mask_all, mask_one, prev_one;
   
    sigfillset(&mask_all);
    sigemptyset(&mask_one);
    sigaddset(&mask_one, SIGCHLD);
    signal(SIGCHLD, handler);
    initjobs(); /* Initialize the job list */
    
    while (1) {
        sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
        if ((pid = fork()) == 0) { /* Child process */
            sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
            execve("/bin/date", argv, NULL);
        }
        sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent process */
        addjob(pid); /* Add the child to the job list */
        sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
    }
    exit(0);
}

void handler(int sig) {
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;
    
    sigfillset(&mask_all);
    while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap child */
        sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
        deletejob(pid); /* Delete the child from the job list */
        sigprocmask(SIG_SETMASK, &prev_all, NULL);
    }
    
    if (errno != ECHILD)
        sio_error("waitpid error");
    errno = olderrno;
}

 

  • 먼저 $\verb|main()|$ 함수에서는 모든 시그널을 Blocking하기 위해 $\verb|sigfillset|$을 사용한다. 따라서 $\verb|addjob(pid)|$ 함수가 실행되는 동안 아무런 시그널의 방해를 받지 않는 것이 보장된다.
  • 시그널이 실행되는 동안에도 또 새로운 시그널이 호출되어 방해될 수 있으므로 시그널 내에도 $\verb|deletejob(pid)|$이 일어나는 동안 모든 signal들을 Blocking 해준다. 따라서 main()과 시그널 핸들러가 동시에 Critical Section에 접근하는 것을 막을 수 있다.
  • $\verb|mask_one|$은 \verb|SIGCHLD| 만을 Blocking 하기 위해서 만든 것이다. $\verb|mask_one|$을 이용하여 $\verb|SIGCHLD|$ 시그널을 막은채로 부모가 $\verb|fork()|$를 실행한다. 따라서 $\verb|addjob()|$ 을 실행하기 전까지는 절대 $\verb|deletejob()|$을 실행할 수 없게 된다.
  • $\verb|fork()|$로 생성된 자식 프로세스의 경우 바로 $\verb|SIGCHLD|$ 시그널을 unblock 해준다. 자식 프로세스에서는 $\verb|SIGCHLD|$ 시그널을 받아도 아무 상관이 없기 때문에 해제해도 상관 없다.
  • $\verb|main()|$에서 $\verb|addjob()|$이 실행됐으므로 $\verb|SIGCHLD|$ 핸들러가 발생해도 상관없으므로 unblock을 해준다.