Sending and Handling Signals in C (kill, signal, sigaction)
- 참고하면 좋은 자료 (만화) : sigterm vs sigkill 차이점
- What is SIGTERM? What's the difference between SIGKILL & SIGTERM?
해당 영상의 내용을 정리한 자료입니다. 문제 시 댓글 주시면 삭제하겠습니다.
최근 42 과제 미니톡을 진행하며, 시그널 프로그래밍에 관심이 생겨 이것저것 유튜브로 강의와 영상을 찾아보고 있었습니다. 그 중에 흥미롭고 짧은 영상이 있어 내용을 정리하여 공유합니다.
시그널이란?
시그널은 당신이 프로세스와 상호작용할 수 있는 가장 간단한 방법 중 하나입니다.
mac이나 리눅스와 같은 모든 unix 스타일 운영체제에서 말이에요.
혹시 리눅스나 맥같은 unix 운영체제를 쓰고있으신가요? 그동안 몰랐겠지만, 당신이 프로그램을 종료시키기 위해 ctrl + C를 누를 때마다 시그널들을 사용하고 있던 겁니다.
시그널 처리 (시그널 핸들러)
그럼 이제 예시를 하나 살펴보도록 할까요.
절대 종료되지 않는 무한루프 프로그램이 있다고 해봅시다. 그냥 영원히 실행되고 있는거에요.
보통 학생들의 경우에 이런 코드는 실수로 작성된 것이겠지만, 이건 설명하려는 목적이니까 일부러 이렇게 만들어봤습니다.
#include <stdlib.h>
#include <unistd.h>
int main()
{
while (1)
{
printf("Wasting your cycles. %d\\n", getpid());
sleep(1);
}
}
이런 경우 아래와 같은 실행 화면이 무한히 나타나게 되는데요. 그러면 당신은 이 화면을 잠시 보다가, 프로세스를 죽이기 위해서 주로 ctrl+C를 누르게 될거에요.
이때 당신은 알지 못했겠지만 프로세스에 SIGINT 시그널을 보낸 것입니다. 이 시그널은 기본적으로 해당 프로세스를 방해하고 그 프로세스를 끝내라고 전해주는 역할을 해요.
아직까진 별로 재미없네요. 그럼 내용을 조금 바꿔서 더 재밌게 해볼까요.
이제는 ctrl + C를 눌렀을 때 다른 일들이 일어나도록 만들어볼거에요.
일단 특정한 시그널을 받았을 때 언제든지 호출되는 그런 함수를 만들어 볼 수가 있어요. 그리고 시그널 함수를 이용해서 그 함수를 등록할 수 있습니다.
그래서 여기서는 SIGINT 시그널을 받았을 때 handler 함수가 실행되도록 했습니다.
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int num)
{
write(STDOUT_FILENO, "I won't die\\n", 13);
}
int main()
{
signal(SIGINT, handler);
while (1)
{
printf("Wasting your cycles. %d\\n", getpid());
sleep(1);
}
}
그럼 이걸 실행시켜 보는 중에 ctrl + C를 누르면 이렇게 나옵니다.
그동안 봐온 것 중 가장 건방진 C 프로그램이 만들어지죠.
프로그램이 절대 죽지 않는다는 데, 그럼 이때는 어떻게 할 수 있나요.
운 좋게도 제가 보낼 수 있는 다른 시그널들이 있네요. 이 프로그램이 자기 process ID를 출력하도록 만든거 기억나시나요? 이 다음 파트를 좀 더 쉽게 하기 위해 만들었던 거였어요.
그럼 이제 SIGTERM이라는 시그널을 보내는 걸 해봅시다.
SIGTERM은 해당 프로세스로 가서 그 프로세스 스스로를 종료시키라고 강하게 명령합니다.
그런데 제가 아직 SIGINT 이외의 시그널들을 보내는 법은 설명하지 않았죠. 바로 kill 명령어를 사용하면 시그널을 보낼 수 있어요.
좀 폭력적인 느낌이 들기도하는 별로인 이름이죠. 그렇지만 꼭 저 말 뜻 그대로 되는 하는 것은 아니에요. 네 가끔은 프로세스들을 죽이기도 하지만 가끔은 그렇지 않다네요...
간단히 말하면, 그냥 메시지를 보내는 거에요.
프로그램 내부에서(코드 중에서) kill()을 호출할 수도 있지만 이런 식으로 명령어로도 실행할 수 있답니다.
$ kill -TERM 28272
내가 보내고 싶은 시그널과 프로세스 ID를 같이 입력해주면?
와 이걸 보세요. 프로세스를 죽이는 데 성공했습니다.
그렇지만 SIGTERM도 같이 처리하려는 제 건방진 자아는 아무도 막을 수 없죠. 이렇게 signal 함수를 통해 SIGTERM에 대한 핸들러를 추가해줍니다.
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int num)
{
write(STDOUT_FILENO, "I won't die\\n", 13);
}
int main()
{
signal(SIGINT, handler);
signal(SIGTERM, handler);
while (1)
{
printf("Wasting your cycles. %d\\n", getpid());
sleep(1);
}
}
그럼 이렇게 나오네요.
$ kill -TERM 28297
kill -TERM 명령어를 입력해봤자 아직 너무 잘 살아있는 상태에요. 그렇지만 운 좋게도 우리에겐 아직 sigkill이라는 방법이 남았습니다. 아마 가끔씩 사람들이 kil -9 이런 이야기를 하는 걸 들어본 적 있을 거에요. 그거랑 같은 방법입니다. 한번 해봅시다!
하하 이제 저 프로세스에게 따끔한 맛을 보여줍시다. 하하 정의의 무게가 느껴지시나요 여러분.
$ kill -KILL 28297
그럼 계속해서 SIGKILL도 핸들러가 관리하도록 해볼까요.
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int num)
{
write(STDOUT_FILENO, "I won't die\\n", 13);
}
int main()
{
signal(SIGINT, handler);
signal(SIGTERM, handler);
signal(SIGKILL, handler);
while (1)
{
printf("Wasting your cycles. %d\\n", getpid());
sleep(1);
}
}
그렇지만 운 좋게도, 그건 먹히지 않을거에요. SIGKILL은 저런 방식으로 처리할 수 없습니다. SIGKILL은 간단히 말해 요청(request)가 아닌 명령(order)이기 때문이에요.
외부 명령어 창에서 KILL -9 28450을 입력해주니 프로세스가 kill 된 것을 볼 수 있습니다.
$ kill -9 28450
시그널의 다양한 역할
시그널은 단지 프로세스를 죽이는(kill) 것 뿐만 아니라, 더 많은 일들을 할 수 있습니다.
예를 들자면, 프로세스를 멈출 수 있고요 (stop).
$ kill -STOP 28486
멈췄던 프로세스를 계속 이어지도록 할 수도 있습니다. (continue)
$ kill -CONT 28486
또, 통신을 목적으로 사용자 정의 시그널 (SIGUSER1, SIGUSER2)를 사용할 수도 있습니다. 그것들은 당신이 정의하고자 하는 대로 정의됩니다.
그리고 정말 특이하게도, 프로그램은 segfault가 되었을 때조차 시그널을 받습니다. 진짜에요.
그럼 이걸 빨리 확인 해보도록 할까요.
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int num)
{
write(STDOUT_FILENO, "I won't die\\n", 13);
}
int main()
{
int *p = NULL;
signal(SIGINT, handler);
signal(SIGTERM, handler);
signal(SIGKILL, handler);
*p = 45 // segfault를 일으킨다
while (1)
{
printf("Wasting your cycles. %d\\n", getpid());
sleep(1);
}
}
위 코드를 실행하면 세그폴트를 일으킬 거에요. 그리고 실제로 확인해봐도 그렇네요.
이때 시그널에서 Segfault를 처리해주었지만 상황을 해결하지는 못했습니다. 잘못된 포인터 문제를 해결하지 못했어요. 시그널을 처리해줘도 잘못된 메모리 접근을 계속할 뿐입니다.
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int num)
{
write(STDOUT_FILENO, "I won't die\\n", 13);
}
void seghandler(int num)
{
write(STDOUT_FILENO, "Seg Fault!\\n", 10);
}
int main()
{
int *p = NULL;
signal(SIGINT, handler);
signal(SIGTERM, handler);
signal(SIGKILL, handler);
*p = 45 // segfault를 일으킨다
while (1)
{
printf("Wasting your cycles. %d\\n", getpid());
sleep(1);
}
}
$ killall a.out
결국 해당 명령어를 통해 프로세스를 종료해주었습니다.
이건 “어떻게 시그널이 작동하며, 세그폴트에서도 보내지는 게 해당 시그널이라는 걸 확인하기 위해서” 보여준 것일 뿐이니, 이런 방식으로 세그폴트를 해결하지는 마세요. 그냥 포인터를 다시 점검해보는게 훨씬 바람직합니다.
시그널의 필요성
오케이 알았어요. 근데 왜 대체 이런 게 필요하죠?
- 프로세스를 kill하는 것 외에도, 사용자 정의 시그널들을 사용하여 어떤 일이 일어났는 지 프로세스에게 알려줄 수 있기 때문입니다.“야 내가 너한테 해줘야하는 그 일 지금 끝냈어.”
- 같이 작동하는 2개의 프로세스가 있을 때, 하나의 프로세스가 작업을 끝내고 나서 다른 프로세스에게 이렇게 시그널을 보낼 수 있어요.
- 다른 흔한 케이스 중 하나는, 어떤 안좋은 일이 생겼을 때 시그널을 처리하도록 하는 것입니다.
- kill 되었을 때나, 누군가 ctrl + C를 눌렀을 때나, kill -9 같은 어떤 경우든 간 상관없습니다. 당신은 프로그램이 종료되기 전에 파일을 닫고 메모리를 해제하는 등의 처리를 해주고 싶을 것입니다. 프로그램이 실제로 종료되기 전에 일관적인 상태로 만드는 작업이요. 이런 걸 항상 사용하지는 않겠지만 일단 그렇다고 치자고요. 가끔 ctrl + C 실행 시 취해지는 행동들이 충분하지 않을 수 있습니다. 가끔씩은 아주 장렬히 실패해야할 수도 있죠.
Sigaction 함수
마지막으로 몇 가지만 더 말해볼게요.
저는 예시에서 signal() 함수만 사용해서 설명했습니다. 왜냐면 그게 제일 간단하기 때문이에요. 그렇지만 signal의 기능들을 포함하는 sigaction이라는 더 새로운 함수가 존재합니다. 이건 더 많은 옵션과 정보를 제공해주며, 당신이 시그널들을 관리할 때 프로세스에 대한 더 많은 제어를 가능하게 해줍니다.
그러니 sigaction을 사용한다면 당신의 코드는 좀 더 이식성이 좋아집니다. 그래서 더 추천되는 방식인 거에요.
(man sigaction을 터미널에 입력해 해당 함수의 메뉴얼을 확인할 수 있음)
이건 제가 sigaction을 사용했을 경우의 원본 코드입니다.
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int num)
{
write(STDOUT_FILENO, "I won't die\\n", 13);
}
void seghandler(int num)
{
write(STDOUT_FILENO, "Seg Fault!\\n", 10);
}
int main()
{
struct sigaction sa;
sa.sa_handler = handler;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
while (1)
{
printf("Wasting your cycles. %d\\n", getpid());
sleep(1);
}
}
그나저나, 이쯤되면 여러분은 제가 printf() 대신에 write() 함수를 사용했다는 사실을 눈치 채셨을 거에요. 이거에는 따로 이유가 있습니다.
핸들러들은 비동기적으로 실행되기 때문에, 그것들은 당신의 코드를 어떤 시점에서나 방해할 수 있습니다. 그러니 여러분들은 sigal handler 안에서 어느 코드나 실행할 수 있는 건 아니에요.
standard library에서 몇몇 함수들은 sync signal safe라고 나열되어있습니다. 이건 그 함수들을 signal handler 안에서 실행하기에 안전하다는 뜻입니다. 다른 코드들과 함께 비동기적으로 실행할 수 있다는 것이죠. 그 항목에는 여러가지 함수들이 존재하는데 printf는 그 항목에 존재하지 않네요. 그래도 write()는 존재합니다. 그래서 이걸 사용한거에요. 또 그게 실제로 printf가 사용하는 것이기도 하니까요. 더 투박하긴 하겠지만 안전하잖아요.
그렇지만 signal handler 함수 내에서 printf를 사용했을 때 그게 항상 충돌한다는 뜻은 아닙니다. 그냥 그게 충돌할 가능성이 있고, 항상 안전함을 보장하지는 않는다는 뜻이죠.
그래도 명백한 이유도 없이 랜덤한 시점에 충돌나는 그런 프로그램은 아무도 좋아하지 않을거에요.
지금까지 signal에 대해 설명했습니다.
여러분들은 이걸 모든 프로젝트에 사용하지는 않을 것이고 분명 매일 사용하지도 않을 거에요. 그래도 이젠 signal이 필요한 경우 그걸 사용할 수 있을겁니다.
이 영상이 여러분들의 미래 프로젝트에 도움이 되었기를 바랍니다.
'IT > 학과 공부' 카테고리의 다른 글
[컴파일러] LL(1) 파서 문제 풀이 (0) | 2022.06.11 |
---|---|
[컴파일러] LL 파서와 LR 파서 (0) | 2022.06.10 |
[KOCW] 운영체제 3차시 - 프로세스 관리 (0) | 2021.11.08 |
[KOCW] 운영체제 3차시 - 운영체제 서비스 (0) | 2021.11.08 |
[KOCW] 운영체제 3차시 - 이중모드, 하드웨어 보호 (0) | 2021.11.08 |