자바스크립트를 활성화 해주세요

[iamroot] 1주차 스터디 회고 (리눅스 커널 내부구조)

 ·  ☕ 15 min read

iamroot 17기 스터디를 다행히 아직 참여하고 있다. 요새 진도 내용을 잘 못 따라가고 있기도 하고, 이전 스터디에 대한 복습이 필요한 것 같아, 내용을 정리하려 한다.

이전보다는 리눅스 커널 소스도 직접 읽어보고, 추가적으로 공부한 지식이 있으니, 당시 있던 의문에 더 정확한 결론을 내릴 수 있을 것 같다.

스터디 초기에는 커널의 내부 구성, 역할 등에 대한 이론을 이해하기 위해 리눅스 커널 내부 구조를 참고해 이론 스터디를 진행했다.

1주차 진도는 Chapter.0 ~ Chapter.3 (p10 ~ p93)이었다.

Chapter.0 운영체제 이야기

Q. 디스크 블록의 크기는 (일반적으로 4KB) 무엇을 기준으로 결정되었는가? (p12)

해당 부분의 맥락으로 보았을 때, 운영체제가 디스크 사용 시 이미 지정된 단위를 기준으로 OS가 제공한다는 점이 핵심이라고 본다. 이와 다른 경우로는 실제 파일의 크기만큼만 디스크를 제공하는 경우다.

컴퓨터 내에서는 특정 단위를 기준으로 자르는 것이 속도 관점에서 더 효율적이며, 여기 디스크 블록을 제외한 다른 자원의 단위도 특정 단위를 기준으로 자른다. 이와 비슷한 예로 페이지, 패킷, CPU time 등이 존재한다.

굳이 4KB를 표기한 것은 일반적으로 페이지 크기가 4KB이기 때문에 언급된 것으로 보인다.

Chapter.2 리눅스 커널 구조

Q. 파일 시스템을 사용자가 일관된 인터페이스로 접근한다는 것이 무슨 뜻인가? (p38)

물리적으로 실제 데이터를 기록하는 파일 시스템은 fat32, ext4 등이 존재한다. 그리고 논리적으로 존재하는 proc, sysfs, devfs 등이 존재한다. 참고로 여기서 내가 의미하는 물리적/논리적의 구분은 기록이 비휘발성인지, 휘발성인지를 기준으로 잡고 있다. (nfs의 경우 물리적으로 실제 데이터를 기록할 때 사용되지만, 파일 시스템의 역할상으로는 논리적으로 수행된다고 볼 수도 있다.)

이들은 모두 리눅스 파일시스템에 마운트 되어 있다.

  • /boot, /bin, /etc 등은 ext4 등으로 포맷된 디스크에 실제로 존재한다.
  • 모든 프로세스와 커널의 정보를 담은 /procprocfs 파일시스템으로 정의되어있다.
  • 연결된 IO장치에 접근하기 위한 /devdevfs 파일시스템으로 정의되어있다.
    게다가 /dev에는 /dev/sda, /dev/hda, /dev/ttyUSB 같은 물리적인 장치 뿐만 아니라,
    /dev/null, /dev/zero, /dev/random 같이 실제 물리적인 장치가 아니지만 자주 사용되는 논리적인 장치가 있다.
  • 연결된 CPU, RAM, IO장치 등의 정보, 커널의 설정 값을 확인하고 변경할 수 있는 /syssysfs 파일시스템으로 정의되어있다.

이들이 모두 일관된 인터페이스로 접근할 수 있는 예시를 확인해보자.

  • 물리적인 디스크 내에 존재하는 파일의 읽기/쓰기는 다양한 프로그램의 소스 코드에서 확인할 수 있다.
    대표적으로 C에서 fopen(), fprintf(), fread() 등을 사용할 수 있다.
  • 물리적인 파일이 아님에도 위와 같이 파일 읽기/쓰기를 활용하여 프로그래밍을 할 수 있다.
    C에서 fprintf(stdout, ...)을 통해 printf와 같이 콘솔로 출력할 수 있다.
  • 커널의 설정을 실행 중 변경할 때는 굳이 텍스트 에디터 등을 사용하지 않고 shell에서 명령어를 통해 변경한다.
    (일반적으로 echo한 내용을 pipe로 write하거나, cat으로 파일의 내용을 읽는다.)
    • # echo nop > /sys/kernel/tracing/current_tracer
      커널의 tracer를 끌 때 (nop) 사용
    • # cat /sys/kernel/tracing/trace
      커널의 trace 내용을 확인하려 할 때 사용
  • 어떤 실행 파일을 실행시키고, 그 출력 결과에는 관심이 없을 때, /dev/null로 pipe시켜 내용을 버린다.
    또한 무작위 값을 입력받기 위해 /dev/random을 사용하기도 한다.
    • $ time ./bench_primes 1 > /dev/null 2>&1
      ./bench_primes의 stdout, stderr를 출력하지 않게 한다.
    • $ dd if=/dev/random of=rand bs=1K count=2
      diskdump할 때 무작위 입력을 위해 /dev/random 파일을 사용한다.

위의 예시에서 확인할 수 있듯, 물리적/논리적인 파일시스템이 여러개 존재하며 모두 /(root filesystem) 아래 하위 디렉터리로 존재한다. 그리고 모든 파일들은 creat(), open(), read(), write(), close() 등의 system call을 통해 접근하게 된다.

VFS(Virtual File System)은 물리적/논리적으로 다른 파일시스템에서의 syscall을 수행할 수 있게 호환시켜주는 역할을 한다.

리눅스의 전신인 유닉스 시절부터 모든 것은 파일이다 철학에 따라 개발되었으므로, 운영체제가 사용자에게 제공하는 모든 기능은 파일의 형태를 이루어 제공되게 되었고, 이에 따라 가상 파일 시스템이 필수적이었던 것으로 예상된다.

Q. 커널 컴파일 과정에서 objcopy가 하는 역할, 의미는 무엇인가? (p43)

컴파일 과정에서 생긴 vmlinux는 virtual memory가 적용된 linux 커널 실행 파일이란 뜻이다.

objcopy 과정을 거치기 위해 사용된 Makefile과 관련 명령어, 결과를 먼저 비교해보자.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#
# arch/arm64/boot/Makefile
#
# This file is included by the global makefile so that you can add your own
# architecture-specific flags and dependencies.
#
# This file is subject to the terms and conditions of the GNU General Public
# License.  See the file "COPYING" in the main directory of this archive
# for more details.
#
# Copyright (C) 2012, ARM Ltd.
# Author: Will Deacon <will.deacon@arm.com>
#
# Based on the ia64 boot/Makefile.
#

OBJCOPYFLAGS_Image :=-O binary -R .note -R .note.gnu.build-id -R .comment -S

targets := Image Image.bz2 Image.gz Image.lz4 Image.lzma Image.lzo

$(obj)/Image: vmlinux FORCE
	$(call if_changed,objcopy)

$(obj)/Image.bz2: $(obj)/Image FORCE
	$(call if_changed,bzip2)

$(obj)/Image.gz: $(obj)/Image FORCE
	$(call if_changed,gzip)

$(obj)/Image.lz4: $(obj)/Image FORCE
	$(call if_changed,lz4)

$(obj)/Image.lzma: $(obj)/Image FORCE
	$(call if_changed,lzma)

$(obj)/Image.lzo: $(obj)/Image FORCE
	$(call if_changed,lzo)

install:
	$(CONFIG_SHELL) $(srctree)/$(src)/install.sh $(KERNELRELEASE) \
	$(obj)/Image System.map "$(INSTALL_PATH)"

zinstall:
	$(CONFIG_SHELL) $(srctree)/$(src)/install.sh $(KERNELRELEASE) \
	$(obj)/Image.gz System.map "$(INSTALL_PATH)"
$ aarch64-linux-gnu-objcopy --help 
Usage: aarch64-linux-gnu-objcopy [option(s)] in-file [out-file]
 Copies a binary file, possibly transforming it in the process
 The options are:
  -I --input-target <bfdname>      Assume input file is in format <bfdname>
  -O --output-target <bfdname>     Create an output file in format <bfdname>
:
  -j --only-section <name>	 Only copy section <name> into the output
     --add-gnu-debuglink=<file>    Add section .gnu_debuglink linking to <file>
  -R --remove-section <name>       Remove section <name> from the output
     --remove-relocations <name>   Remove relocations from section <name>
  -S --strip-all		   Remove all symbol and relocation information
:
  -V --version		     Display this program's version number
  -h --help			Display this output
     --info			List object formats & architectures supported
aarch64-linux-gnu-objcopy: supported targets: elf64-littleaarch64 elf64-bigaarch64 elf32-littleaarch64 elf32-bigaarch64 elf32-littlearm elf32-bigarm elf64-little elf64-big elf32-little elf32-big srec symbolsrec verilog tekhex binary ihex plugin
Report bugs to <http://www.sourceware.org/bugzilla/>

# 커널 컴파일을 통해 얻은 리눅스 커널 실행파일 vmlinux가 어떤 파일인지 확인해보자.
$ file vmlinux
vmlinux: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), statically linked, BuildID[sha1]=33523a2bcbaf40ff140171d6169def75f68aadcd, with debug_info, not stripped
# 보다시피 ELF 형식의 파일이다.

# 커널 컴파일 과정에서 objcopy로 생성된 Image를 확인해보자.
$ file arch/arm64/boot/Image
arch/arm64/boot/Image: MS-DOS executable PE32+ executable (EFI application) Aarch64 (stripped to external PDB), for MS Windows
# 현재 커널 config에 EFI 지원이 있어, MS-DOS 실행파일의 결과물로 나타난다.

# arch/arm64/boot/Makefile 내에 있는 objcopy 플래그를 그대로 사용하여 Image를 직접 생성해보자.
$ aarch64-linux-gnu-objcopy -O binary -R .note -R .note.gnu.build-id -R .comment -S vmlinux Image

# 직접 명령어로 생성한 Image와 커널 컴파일의 결과로 나온 Image와 비교해보자.
$ diff Image arch/arm64/boot/Image
# 동일한 바이너리 파일이므로 특별한 결과가 출력되지 않는다.

실행된 objcopy 명령의 옵션을 확인해보면, binary 형식으로 obj(컴파일 과정에서 설명하는 그 목적 코드)들을 복사하는데, 이 때 .note, .comment 등의 섹션은 완전히 지우고, strip을 하게 되는데, 이 과정에서 실제 실행에 불필요한 모든 정보를 지우게 된다.

즉 커널을 실행하기 위해 필요한 순수한 기계어만 남기는 과정이라 볼 수 있다.

Chapter.3 태스크 관리

Q. execl()을 통해 기존 프로세스의 수행 이미지가 바뀌는 과정에서 내부적으로 do_fork()가 일어나는 것이 아닐까? (p57)

execl() 함수가 호출될 때, struct task_struct의 관계(PID, 부모 task 등)는 그대로 유지되지만 새로운 이미지를 실행하기 위한 부분(.text, .data, .stack, …)만 변경된다.

직접 예시로 나온 코드를 수정해서 확실한 관계를 확인해보자. 어떤 실행 파일이 출력을 하고 있는 것인지, PID가 몇 번인지 매번 확인할 수 있게 수정했다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[])
{
	pid_t pid;
	int exit_status;

	printf("%s (%d): Before fork\n", argv[0], getpid());
	if((pid=fork())<0){
		perror("fork error");
		exit(1);
	} else if(pid == 0) {
		printf("%s (%d): Before exec\n", argv[0], getpid());
		execl("./fork", "fork", (char *)0);
		printf("%s (%d): After exec\n", argv[0], getpid());
	} else {
		pid = wait(&exit_status);
	}

	printf("%s (%d): Parent\n", argv[0], getpid());
	return 0;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int g = 2;

int main(int argc, char* argv[])
{
	pid_t pid;
	int l = 3;
	int exit_status;

	printf("%s (%d): Parent g=%d, l=%d\n", argv[0], getpid(), g, l);

	if((pid=fork())<0){
		perror("fork error");
		exit(1);
	} else if(pid == 0) {
		g++;
		l++;
	} else {
		pid = wait(&exit_status);
	}

	printf("%s (%d): g=%d, l=%d\n", argv[0], getpid(), g, l);

	return 0;
}
# 각 소스 코드들을 컴파일한다.
$ gcc fork.c -o fork
$ gcc fork_exec.c -o fork_exec

$ ./fork_exec
./fork_exec (3319361): Before fork
./fork_exec (3319362): Before exec
fork (3319362): Parent g=2, l=3
fork (3319363): g=3, l=4
fork (3319362): g=2, l=3
./fork_exec (3319361): Parent
  1. fork_exec에서 fork() 함수를 호출하기 전의 프로세스(3319361)애서 “Before fork"를 출력한다.
  2. fork_exec에서 fork() 함수가 호출되었고, 그 중 자식 프로세스(3319362)에서 “Before exec"를 출력한다.
  3. fork_exec의 자식 프로세스(3319362)가 execl() 함수를 통해 다른 실행파일인 fork를 실행한다.
    (새로 실행시키는 것이 아닌, 실행 이미지를 교체한다.) 여전히 같은 프로세스 ID(3319362)를 가지는 것을 확인할 수 있다.
  4. 교체된 fork 프로세스(3319362)가 fork() 함수를 호출했고, 그 새로운 자식인 fork 프로세스 (3319363)가 생성되었다.
  5. 교체된 fork 프로세스(3319362)는 자신의 자식 프로세스 (3319363)이 종료되는 것을 기다린 뒤 출력하고 종료된다.
  6. 교체된 fork 프로세스(3319362)의 부모 프로세스 (3319361)는 자식 프로세스가 종료된 것을 확인한 뒤 “Parent"를 출력한다.
  7. 교체된 fork 프로세스(3319362)는 이미지가 fork_exec에서 fork로 교체되었기 때문에 “After exec"는 출력되지 않는다.

이 결과를 통해 이미지가 바뀌는 과정에서 do_fork()는 호출되지 않는 것을 확인할 수 있다. (더 정확한 실험을 하려면 trace를 해서 do_fork()가 호출되지 않았음을 증명하면 되지만, 일단 do_fork()의 결과로 PID가 바뀐다는 것을 통해 증명했다.)

사실 이 질문이 생긴 원인은 하필 execl()로 교체하는 프로그램이 fork()를 호출하는 프로그램이라 헷갈리기 쉬웠던 것 같다.

Q. 리눅스가 지원한다는 Linux exec 도메인, BSD나 SVR4 exec 도메인은 무슨 뜻인가? (p69)

해당 내용은 Understanding the linux kernel 내용을 참고하여 설명하도록 하겠다.

리눅스는 다른 운영체제 용으로 컴파일 된 실행파일을 실행해 줄 수도 있다. (단, 같은 아키텍쳐라서 동일한 기계어를 실행할 수 있을 때를 기준으로 한다.)

여기서 다른 운영체제라고 무조건 실행할 수 있는 것이 아니다.

  • 에뮬레이션 된 실행: POSIX 호환되지 않는 시스템 콜이 있을 경우, 이에 대한 에뮬레이션을 지원해야 한다.
  • Native 실행: 모든 시스템 콜이 POSIX 호환되어, 그대로 실행할 수 있다.

MS-DOS나 Windows의 경우, API 자체가 리눅스와 다르다. (당연히 UNIX를 뿌리로 두지 않기 때문에) 그래서 이를 에뮬레이션 하기 위해 Wine이나 DOSemu 등을 통해 API를 리눅스 환경에 맞게 변환해 주는 작업이 필요하다. (ex. WinAPI -> Syscall)

personality에 대한 설명을 읽어보면 해당 실행 파일이 (다른 운영체제 용으로 컴파일된) 커널의 기능을 얼마나 지원할 수 있는지에 대한 flag 형식으로 표현되는 것을 확인할 수 있다.

이 부분은 리눅스 커널 분석 과정에서 중요한 부분이라기 보단, 이런 기능도 리눅스 커널에서 제공된다라고 하고 넘어갈 수 있을 것 같다. (해당 부분에 관심 있는 사람이라면 모를까)

Q. n개의 CPU를 갖는 시스템에서는 임의의 시점에 최대 n개의 task라는 것이 Physical core인가? Logical processor인가? (p70)

하드웨어 제조사들마다 다른 용어를 쓰는 바람에 조금 헷갈릴 수도 있는데, 아래와 같이 정의해보겠다. (좀 더 정확한 정의는 추후 시도해 보도록 하겠다.)

  • Physical core: 물리적으로 구별되는 CPU,
    일반적으로 제품 소개에 core라고 표기됨 (ex. Dual core -> Physical core 2개)
  • Logical processor: 논리적으로 구별되는 CPU, 하이퍼쓰레드SMT 기술로 구현된 CPU 단위,
    최근 제품들의 경우 1개 physical core 당 2개의 logical processor가 지원되며, 전체 개수는 physical core의 개수보다 작을 수 없다.
    일반적으로 제품 소개에 thread라고 표기됨

추후 책에서 runqueue를 다룰 때, Logical processor를 뜻하는 것으로 예측된다. (p77)

리눅스에서 $ cat /proc/cpuinfo를 해보면 Logical processor의 개수만큼 나타난다. 즉 리눅스 커널에서는 Logical processor를 CPU 단위로 취급하는 것으로 보인다.

Q. EXIT_ZOMBIE 상태가 유지되는 경우(시스템에 불필요한 부하를 주는 상태)는 어떤 경우인가? (p71)

일반적인 프로세스의 경우, 부모 프로세스가 wait() 함수를 호출하여 자식 프로세스의 상태가 EXIT_DEAD로 바뀌게 허가해준다. 이 개념대로라면 부모가 wait() 함수를 호출하지 못하고 죽으면(ex. SEGFAULT) 자식 프로세스는 좀비 프로세스 상태가 계속 유지되는 것이라 생각하고 있었다.

하지만 wait() 시스템 콜의 설명에서 아래와 같은 설명이 있다. (man 2 wait에서 NOTES 부분을 확인해 보면 자세한 설명이 나와있다.)

A child that terminates, but has not been waited for becomes a “zombie”. The kernel maintains a minimal set of information about the zombie process (PID, termination status, resource usage information) in order to allow the parent to later perform a wait to obtain information about the child. As long as a zombie is not removed from the system via a wait, it will consume a slot in the kernel process table, and if this table fills, it will not be possible to create further processes. If a parent process terminates, then its “zombie” children (if any) are adopted by init(1), (or by the nearest “subreaper” process as defined through the use of the prctl(2) PR_SET_CHILD_SUBREAPER operation); init(1) automatically performs a wait to remove the zombies.

해석하면 아래와 같다.

자식 프로세스가 종료되었지만 부모 프로세스로부터 wait을 호출받지 못하면 좀비 프로세스가 된다. 커널은 좀비 프로세스에 대한 최소한의 정보(PID, 종료 상태, 자원 사용 정보)를 유지하여, 추후 부모 프로세스가 나중에 wait을 했을 때 이 정보들을 얻을 수 있게 한다. 좀비 프로세스가 wait으로 제거되지 않는 상태로 유지된다면, 커널의 프로세스 테이블의 한 칸을 계속 소모하고 있는 것이며, 해당 테이블이 꽉 찰 경우 추후 새로운 프로세스를 생성할 수 없게 된다. 만약 부모 프로세스가 종료되면, 그 프로세스에 딸려 있던 좀비 자식 프로세스들은 init 프로세스에게 입양된다. (혹은 prctl 시스템콜의 PR_SET_CHILD_SUBREAPER 동작을 수행한 가장 가까운 subreaper에게 입양된다.) init은 자동으로 wait을 수행하여 좀비 프로세스들을 삭제한다.

좀비 프로세스가 되는 데 가장 중요한 것은 wait()의 호출이 이루어 지지 않는 것이었다.

직접 내가 생각했던 방법과, wait의 설명에서 한 내용을 실험해보자.

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

int main(void) {
	pid_t pid;
	int status;

	if ((pid = fork()) < 0) {
		perror("fork");
		exit(1);
	}

	if (pid == 0)
	{
		/* Child waits 50 second and terminate */
		sleep(50);
		exit(0);
	}
	else
	{
		/* Parent will be terminated before the child's termination */
		char *msg = "Test";
		msg[0] = 'S'; /* triggering segfault */
	}

	return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void) {
	pid_t pid;
	int status;

	if ((pid = fork()) < 0) {
		perror("fork");
		exit(1);
	}

	/* Child */
	if (pid == 0)
	{
		/* Child terminate immediately */
		exit(0);
	}
	else
	{
		/* Parent waits 50 second and terminate */
		sleep(50);
	}

	return 0;
}
# 실행파일로 컴파일한다.
$ gcc parent_segfault.c -o parent_segfault

# parent_segfault 실행한다. (ps로 결과를 확인해야 하므로 &을 붙여 백그라운드 실행시킨다.)
$ ./parent_segfault &
[1] 1244753
[1]  + 1244753 segmentation fault (core dumped)  ./parent_segfault

# 바로 ps 명령으로 ./parent_segfault의 자식 프로세스 상태를 조회해 보자.
$ ps -ef | grep parent_segfault
jsyoo5b  1244755       1  0 13:40 pts/2    00:00:00 ./parent_segfault
jsyoo5b  1245371 4116883  0 13:40 pts/2    00:00:00 grep ... # grep 명령의 process

# 50초 뒤에 다시 조회하여 zombie상태인지 확인해보자.
$ ps -ef | grep parent_segfault
jsyoo5b  1247619 4116883  0 13:42 pts/2    00:00:00 grep ... # grep 명령의 process
# 아무 결과도 나타나지 않는다.
# 실행파일로 컴파일한다.
$ gcc no_wait.c -o no_wait

# no_wait 실행한다. (ps로 결과를 확인해야 하므로 &을 붙여 백그라운드 실행시킨다.)
$ ./no_wait &
[1] 1257718

# 바로 ps 명령으로 ./no_wait의 자식 프로세스 상태를 조회해 보자.
$ ps -ef | grep no_wait
jsyoo5b  1257718 4116883  0 13:47 pts/2    00:00:00 ./no_wait
jsyoo5b  1257720 1257718  0 13:47 pts/2    00:00:00 [no_wait] <defunct>
jsyoo5b  1257956 4116883  0 13:47 pts/2    00:00:00 grep ... # grep 명령의 process

# 50초 뒤에 다시 조회하여 zombie상태인지 확인해보자.
$ ps -ef | grep no_wait
jsyoo5b  1264140 4116883  0 13:51 pts/2    00:00:00 grep ... # grep 명령의 process
# 아무 결과도 나타나지 않는다.

위의 실험에서 parent_segfault는 부모 프로세스가 강제 종료될 수 있도록 강제로 SEGFAULT를 일으켰다. 부모 프로세스가 강제 종료되면 부모 프로세스가 1(init 프로세스)으로 변경된 것을 볼 수 있다.

반대로 자식이 먼저 종료되도록 부모가 wait()을 호출하지 않는 경우, 자식 프로세스는 <defunct> 상태가 되는데, 이 상태가 좀비 프로세스 상태임을 의미한다. 자식 프로세스는 50초동안 부모 프로세스에서 wait()를 호출하길 기다리며 좀비 프로세스 상태였지만, 부모 프로세스는 그냥 종료되었고, 아마 1(init 프로세스)에게 입양되어, wait() 처리를 통해 종료되었을 것이다.

Q. SIGKILL이 발생하는 경우는 어떤 경우인가? (p72)

스터디 당시 Ctrl+C로 발생시키는 것이 아니냐고 했는데, Ctrl+C는 SIGKILL이 아니라 SIGINT(Interrupt Signal)다. 아마 Ctrl+C를 통해 실행 중이던 프로세스를 종료시키기 때문에 그런 것으로 생각할텐데, 정확하게 설명하자면 SIGINT의 기본 핸들러가 해당 프로세스의 종료기 때문이다.

SIGINT의 핸들러를 직접 정의하는 예제를 간단하게 짜 보았다. 참고로 signal()을 사용하는 것은 좋지 않으며, sigaction()을 사용하는 것이 좋다고 알고 있으나, 이 부분은 추후 제대로 공부하고 글을 쓰도록 하겠다. (게다가 간단한 예제라서 공을 들이지 않은 것도 있다.)

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

#define SIGINT_THRESHOLD 3

void sigint_hndl(int signo)
{
	static int sigint_cnt = 0;
	if (signo == SIGINT)
	{
		sigint_cnt++;
		if (sigint_cnt < SIGINT_THRESHOLD)
		{
			printf(" Send %d more SIGINT to terminate\n",
				(SIGINT_THRESHOLD - sigint_cnt));
		}
		else
		{
			exit(0);
		}
	}
}

int main(void)
{
	signal(SIGINT, sigint_hndl);

	while(1)
		sleep(1);
	return 0;
}
# 실행파일로 컴파일한다.
$ gcc sigint_hndl.c -o sigint_hndl

# sigint_hndl을 실행하고 Ctrl+C를 3번 입력하여 종료시킨다.
$ ./sigint_hndl
^C Send 2 more SIGINT to terminate
^C Send 1 more SIGINT to terminate
^C 
# Ctrl+C를 3번째 입력받고 나서야 종료된다. (signal handler에 작성한 대로 동작함)

다시 본론으로 돌아와서, SIGKILL은 명시적으로 호출했을 때만 발생한다. 대표적인 경우로 kill -9 $PID와 같은 명령어를 통해 종료시키는 방법이 있다. (여기서 9는 SIGKILL의 portable number다.)

프로세스를 종료시키는 시그널 종류는 SIGTERM, SIGINT, SIGQUIT, SIGKILL, SIGHUP이 존재한다.

  • SIGTERM은 종료 요청으로, kill 명령이 보내는 기본 시그널이다.
    GUI에서 종료버튼을 누르거나, Task Manager에서 종료 요청을 보낼 때와 비슷하다고 볼 수 있다.
    문서 작업 프로그램의 경우 종료 요청 시 저장하겠냐고 다시 물어보는 동작을 하듯, 사용자가 작성한 핸들러를 동작시킬 수 있다.
  • SIGINT는 위에서 설명했듯 Program interrupt를 보내는 것이다. INTR 글자의 가장 일반적인 예가 우리가 아는 Ctrl+C이다.
  • SIGQUITSIGINT와 비슷하지만 다른 QUIT 글자 (Ctrl+\)로 발생되며, core dump를 발생시킨다.
    사용자가 직접 프로그램 에러를 발생시키며 종료시키는 경우라 볼 수 있다. (다른 예시로는 SEGFAULT 등의 오류로 인해 프로그램에서 에러 시그널을 발생하는 경우가 있다.)
  • SIGHUP는 hang-up 신호로, 사용자 터미널의 연결이 끊긴 경우 이를 알려 동작중이던 프로세스들이 종료되라고 알리는 것이다. (네트워크 연결 끊김 등)

위 예시된 시그널등과 달리 SIGKILL은 해당 프로세스의 의사와 관계 없이 (핸들러를 등록하지 못하고) 강제 종료할 때 사용된다.

시스템을 종료시키는 shutdown의 경우, SIGTERMSIGKILL의 용도를 확실히 알 수 있게 해 준다.

  1. 먼저 모든 프로세스에 SIGTERM을 보내, 자발적으로 종료되길 기다린다.
  2. 혹시나 SIGTERM에도 종료되지 않은 프로세스들을 강제 종료시키기 위해 SIGKILL을 보낸다.

일부 시스템 관리 프로그램의 경우, lock을 잡고 있거나, db를 수정하고 있는 데 SIGKILL만으로 종료하면 프로그램의 상태가 이상해진 상태로 종료되므로, 기본적으로는 SIGTERM으로 종료시키는 것이 적합하다.


JaeSang Yoo
글쓴이
JaeSang Yoo
The Programmer

목차