Search
Duplicate
🗣️

minitalk (4) 시그널 주의사항 ⚠️

간단소개
팔만코딩경 컨트리뷰터
ContributorNotionAccount
주제 / 분류
C
42cursus
minitalk
태그
Scrap
8 more properties
minitalk

안전한 시그널 사용을 위한 수칙

시그널 핸들러는 비동기 작업을 지원하기 때문에 데이터 레이스가 자주 발생하며, 다양한 원인과 증상이 존재한다.

시그널 핸들러는 가능한 간단하게 유지해야 한다.

문제점

리눅스 시스템은 같은 유형의 시그널이 이미 보류 중일 경우 시그널을 대기열에 넣지 않는다.
보류 시그널은 일종의 플래그 처럼 동작하며 SIGUSR1이 연속 3번 전달되었다고 가정할 때의 슈도 코드는 다음과 같다.
1.
시그널을 처리한다.
2.
시그널을 처리 중이므로 대기열에 SIGUSR1을 넣는다.
pending[SIGUSR1] = 1;
C
3.
시그널을 처리 중이므로 대기열에 SIGUSR1을 넣는다. (누락 발생!)
pending[SIGUSR1] = 1; // 덮어씌워지므로 누락 발생
C
4.
(1)에서의 시그널 처리가 끝나 보류 중인 시그널을 가져온다.
pending[SIGUSR1] = 0;
C
즉, 시그널 핸들러의 수행 시간이 길어질수록 누락되는 시그널이 많아지게 된다.

해결방법

가장 좋은 해결책은 시그널은 단 한 번만 호출되도록 설계하고 단순 플래그 설정으로 끝내는 것이다.
void handler(int sig) { flag = sig; }
C
하지만 minitalk에서는 연속적으로 시그널을 받아야하므로 여러 방법으로 우회를 해야한다.
[방안1] 클라이언트에서 핸들러 처리가 끝날 만큼의 시간을 대기하고 다음 시그널을 보낸다.
100 글자를 전송하는데 1초가 소요되면 굉장히 느린 겁니다
과제에 따르면 아무리 느려도 1초 당 100개의 문자 전송 (100 baud)가 요구된다.
1baud가 8bit로 구성된 경우, 100 baud는 1초 당 100 * 8 비트 전송 (800bps)가 요구된다.
800bps 처리를 위해서는 1초 = 1000000 마이크로초 이므로,
1000000 / 800 = 1250 이므로, 비트 사이의 시간은 1250 보다 작아야 한다.
[참고] 직렬 통신에서 일반적으로 사용하는 BAUD와 비트 별 계산 표
처리 방법
server는 별도의 sleep 없이 시그널을 대기한다.
client는 적절한 sleep을 통해 시그널 전송 속도를 적절히 조절한다.
전송 속도가 서버의 시그널 처리보다 빠른 경우, 시그널이 누락 확률이 높다.
전송 속도가 비트 사이 시간보다 매우 느린 만큼 메시지 전송이 느려진다.
[방안2] 최대한 핸들러를 짧게 작성하고 서버에서 응답 시그널을 통해 재전송을 요청한다.
직렬 통신 관련 표준 규격인 RS485에서는 충돌 감지를 위한 모드가 존재한다.
모드
설명
Echo Mode
- 성공 시, 전송 데이터를 그대로 루프백한다. - 충돌 시, 루프백이 달라지므로 데이터가 깨졌음을 알 수 있다. - 충돌을 감지하면 랜덤한 시간만큼 대기한 후 다시 데이터를 전송한다.
Non-Echo Mode
- 성공 시, ACK 응답을 받는다. - 충돌 시, ACK 응답이 오지 않으므로 일정 시간 뒤에 재전송을 시도한다. ※ ACK 응답이 충돌난 경우 동일 데이터 재전송이 발생할 수 있다.
Echo Mode의 개념을 적용한 시그널 처리 방식은 다음과 같다.
서버의 처리가 성공한 경우, 동일한 시그널을 응답하여 클라이언트에 성공을 알린다. (다음 시그널 전송)
서버의 처리가 실패한 경우, 반대의 시그널을 응답하여 클라이언트에 실패를 알린다. (동일 시그널 전송)
시간 내에 응답이 오지 않는 경우 타임아웃으로 처리한다. (클라이언트 종료)

시그널 핸들러에서 사용하는 공유 객체는 volatile sig_atomic_t 유형을 사용해야 한다.

시그널 핸들러에서 공유 개체 접근은 데이터 레이스를 발생시킬 수 있으므로 금지된다.
단, volatile sig_atomic_t타입과 lock-free 객체는 허용되며 그 외의 경우는 정의되지 않은 행위(undefined behavior)으로 간주한다.
voaltile sig_atomic_t 타입의 경우 읽기/쓰기에 대한 원자성을 보장하여 데이터 레이스가 발생하지 않는다.
volatile sig_atomic_t flag; if (flag) ... // 읽기에 대해서 안전하다. (객체 참조 목적) flag = 12345; // 쓰기에 대해서 안전하다. (단순 플래그 목적)
C
단, flag++ 또는 flag += 10과 같이 다수의 명령을 필요하는 동작은 안전성을 보장하지 않으므로 상호 배제 기법이 필요하다.
movl flag, %ecx ; 레지스터에 flag 읽기 addl $1, %ecx ; 레지스터의 값을 1 증가 movl %ecx, flag ; 레지스터의 값을 flag 쓰기
Assembly

비준수 코드

volatile sig_atomic_t flag; void handler(int sig) { flag++; } void main(void) { ... flag--; ... }
C
위 코드의 시퀀스 다이어그램은 다음과 같다.
의도는 핸들러에 의해 8으로 증가하고, 메인에 의해 7로 감소되어야 한다.
하지만 레지스터 연산 중 시그널이 호출되어 최종 결과는 6이 된다.
sequenceDiagram participant main_reg participant main participant handler participant handler_reg Note right of main: flag = 7; main->>main_reg: flag-- 연산 Note left of main_reg: register = 7; main->>handler: signal 발생 handler->>handler_reg: flag++ 연산 Note right of handler_reg: register = 7; handler_reg->>handler_reg: 레지스터 증가 Note right of handler_reg: register = 8; handler_reg->>handler: flag++ 반환 Note right of main: flag = 8; handler->>main: signal 종료 main_reg->>main_reg: 레지스터 감소 Note left of main_reg: register = 6; main_reg->>main: flag-- 반환 Note right of main: flag = 6;
Mermaid

준수 코드

volatile sig_atomic_t flag; // lock-free 객체 volatile sig_atomic_t lock; // volatile sig_atomic_t 객체 void handler(int sig) { if (!lock) flag++; } void main(void) { lock = 1; flag--; lock = 0; }
C
위 코드의 시퀀스 다이어그램은 다음과 같다.
lock 플래그에 의해 flag 연산으로 인한 데이터 레이스에는 안전하다.
단, 누락 시그널은 아직 처리하지 않았으므로 문자가 깨질 수는 있다.
sequenceDiagram participant main_reg participant main participant handler Note right of main: flag = 7; main->>main_reg: flag-- 연산 Note left of main_reg: register = 7; main->>handler: signal 발생 Note right of handler: lock 으로 인해 시그널 무시 handler->>main: signal 종료 main_reg->>main_reg: 레지스터 감소 Note left of main_reg: register = 6; main_reg->>main: flag-- 반환 Note right of main: flag = 6;
Mermaid

시그널 핸들러는 errno를 저장하고 복원해야 한다.

대부분의 비동기 안전 함수에서 에러를 반환할 때 errno가 설정된다. 핸들러 내부에서 errno가 설정될 경우 외부에서 문제가 발생 할 수 있다.

비준수 코드

void hander(int sig) { ... write(-1344, "", 2); // EBADF 발생 ... } void main(void) { errno = ERANGE; kill(getpid(), sig); printf("%s\n", strerror(errno)); // ERANGE 가 아닌 EBADF 오류 출력 }
C

준수 코드

void hander(int sig) { int old_errno = errno; write(-1344, "", 2); // EBADF 발생 errno = old_errno; } void main(void) { errno = ERANGE; kill(getpid(), sig); printf("%s\n", strerror(errno)); // ERANGE 정상 출력 }
C

시그널 핸들러는 비동기 안전 함수만 호출해야 한다.

[참고] POSIX 표준 비동기 안전 함수 목록
[참고] OPEN BSD 비동기 안전 함수 목록 (see signal(7))
비동기 안전 함수가 아닌 함수들은 간접적으로 스택에 없는 메모리. 즉, 공유 객체에 대한 접근을 할 수 있으므로 사용해서는 안된다.
malloc 과 free는 메모리 관리를 위해 전역 또는 정적 공간을 사용하므로 비동기에 안전하지 않다.
printf 같은 함수에서는 malloc 등을 간접적으로 사용하므로 비동기에 안전하지 않다.
따라서, 버퍼 스트림을 유지하지 않는 write 함수는 비동기에 안전하지만 printf는 비동기에 안전하지 않다.

비준수 코드

void hander(int sig) { ... ft_printf("pid : %d\n"); // 비동기에 안전하지 않음 ... } void main(void) { ft_printf("pid : %d\n", getpid()); while (1) ; }
C

준수 코드

void hander(int sig) { ft_putstr_fd("sig : ", 1); // async-safe한 write만 사용 ft_putnbr_fd(sig, 1); // async-safe한 write만 사용 ft_putstr_fd("\n", 1); // async-safe한 write만 사용 } void main(void) { ft_printf("pid : %d\n", getpid()); while (1) ; }
C

시그널 핸들러를 블록시켜 공유 객체에 대한 접근을 보호해야 한다.

시그널 핸들러를 블록하지 않는 경우 시그널 핸들러 처리 도중에 시그널 핸들러가 재호출 될 수 있다.
이 경우, 다른 핸들러와의 공유 객체 접근에 해당되어 데이터 레이스에 안전하지 못하므로 동일한 공유 객체를 접근하는 시그널 및 핸들러는 모두 블록해야 한다.

비준수 코드

void hander(int sig) { ... sigaction(SIGUSR1, &act, NULL); // 설정 도중 핸들러 발생 시 데이터 레이스 발생 ... } void main(void) { struct sigatcion act; sigemptyset(&act.sa_mask); //sigaddset(&act.sa_mask, SIGUSR1); //block SIGUSR1 //sigaddset(&act.sa_mask, SIGUSR2); //block SIGUSR2 act.sa_flags = SA_RESTART; sigaction(SIGUSR1, &act, NULL); sigaction(SIGUSR2, &act, NULL); ... }
C

준수 코드

void hander(int sig) { ... sigaction(SIGUSR1, &act, NULL); // 블록된 상태라 안전 ... } void main(void) { struct sigatcion act; sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask, SIGUSR1); //block SIGUSR1 sigaddset(&act.sa_mask, SIGUSR2); //block SIGUSR2 act.sa_flags = SA_RESTART; sigaction(SIGUSR1, &act, NULL); sigaction(SIGUSR2, &act, NULL); ... }
C

시그널 대기 시 주의사항

pause

데이터 레이스가 발생해 많은 타이밍 버그를 양산할 수 있으므로 주의해야 한다.
시그널 내부에서 모든 데이터를 처리하는 경우에는 안전하나 메인과 결합하는 경우 문제가 발생할 수 있다.
예를 들어, 조건문을 확인하고 시그널이 전달된 다음 pause가 호출되는 경우 무한 대기가 발생할 수 있다.
의도적으로 딜레이를 삽입하는 등 억지로 타이밍을 맞추더라도 커널의 Task scheduler 에 의해 타이밍이 어긋나는 경우가 종종 발생한다.
while (1) { // 이 사이에서 시그널이 발생하는 경우 pause()는 무한 대기 상태에 빠진다. pause(); something(); }
C

while

데이터 레이스에 안전하지만 시스템 자원을 많이 사용한다.
짧게는 1초에서 길게는 수 시간 까지 대기해야하는 경우, 프로세스 입장에서 의미없는 행동을 반복하면서 리소스를 낭비하게 된다.
while (1) { while (!sig) ; something(); }
C

sleep

데이터 레이스에 안전하고 시스템 자원을 적게 소모하지만 프로그램이 느려질 수 있다.
시그널이 호출되지 않아도 조건문을 확인하기 때문에 무한 반복에 빠지지 않는다. (단, sleep의 반환값으로 TIMOUT을 확인하는 경우 pause와 같은 문제가 발생할 수 있다)
while (1) { while (!sig) sleep(1); something(); }
C

sigsuspend

데이터 레이스에 안전하지만 minitalk에서는 허용 함수가 아니다.
지정된 시그널이 발생할 때 까지 프로세스를 잠시 중지시키므로 자원을 적게 사용한다.
while (1) { while(!sig) sigsuspend(&act.sa_mask); something(); }
C

참고

Prev