유닉스 프로그래맹(UNIX Programing) 프로세스 관리

8. 프로세스 관리 

8.1 소개



우리는 이제 유닉스에 의해 제공되는 프로세스 관리에 관해 배울 것이다. 이것은 새로운 프로세스의 생성과 프로그램의 실행과 프로세스의 종료를 포함한다. 우리는 또한 프로세스의 속성을 나타내는 다양한 ID들--real, effective, saved 사용자와 그룹 ID들--에 대해 배울 것이고, 어떻게 그것들이 프로세스 관리 근본에 의해서 영향을 받는지를 알아볼 것이다. 우리는 이 장을 대부분의 유닉스 시스템에서 제공하는 프로세스 계정에 대해 알아보면서 마칠 것이다. 이것은 프로세스 관리 함수들을 다른 관점에서 바라보게 해 줄 것이다.

8.2 프로세스 ID

모든 프로세스는 고유한 프로세스 ID를 가지고 있다. 이것은 음이 아닌 정수값이다. 프로세스 ID가 프로세스의 ID 중에서 항상 유일한 것으로 잘 알려졌기 때문에, 이것은 때때로 유일함을 보장받을 필요가 있는 다른 ID로서 사용되기도 한다. 섹션 5.13의 tmpnam 함수는 유일한 경로명을 만들 때, 그 이름 안에 프로세스 ID를 포함한다.
어떤 특별한 프로세스들이 있다. 프로세스 ID 0은 대게 scheduler 프로세스이고, 이것은 swapper라고 대게 알려져 있다. 디스크상의 어떤 프로그램도 이 프로세스와 관련되어 있지 않다. 이것은 커널의 일부이고, 시스템 프로세스라고 알려져 있다. 프로세스 ID 1은 대게 init 프로세스이고, bootstrap 프로시져의 마지막에 커널에의해 불려진다. 이 프로세스의 프로그램 파일은 예전 버전의 유닉스에서 /etc/init이고 최신 버전에서는 /sbin/init이다. 이 프로세스는 커널이 bootstrap된 이후에 유닉스 시스템을 띄우는 일을 맡는다. init은 대게 시스템 독립적인 초기화 파일들을 읽고(대게 /etc/rc* 파일들) 시스템을 (멀티 유저와 같은)특정한 상태로 만드는 일을 한다. init 프로세스는 절대 죽지 않는다. 비록 이것이 슈퍼유저의 권한으로 실행되긴 하지만, 이것은 (커널 내부의 swapper와 같은 시스템 프로세스가 아니라) 일반 프로세스이다. 이 장의 뒷부분에서 우리는 어떻게 init이 모든 고아가 된 자식 프로세스들의 부모 뒹도우가 되는 가를 알아볼 것이다.
유닉스의 어떤 가상 메모리 구현에서, 프로세스 ID 2는 pagedaemon이 된다. 이 프로세스는 가상 메모리 시스템의 페이징을 지원하는 책임을 맡는다. swapper와 같이 pagedaemon은 커널 프로세스이다.
프로세스 ID와 더불어 모든 프로세스마다 또다른 ID들이 있다. 다음 함수들은 이 ID들을 리턴해 준다.
#include
#include
pid_t getpid(void); 리턴값: 호출한 프로세스의 프로세스 ID
pid_t getppid(void); 리턴값: 호출한 프로세스의 부모 프로세스 ID
pid_t getuid(void); 리턴값: 호출한 프로세스의 real user ID
pid_t geteuid(void); 리턴값: 호출한 프로세스의 effective user ID
pid_t getgid(void); 리턴값: 호출한 프로세스의 real group ID
pid_t getegid(void); 리턴값: 호출한 프로세스의 effective group ID
이 함수들은 에러를 리턴하지 않음을 명심하라. 우리는 다음 섹션에서 fork 함수를 배울 때 부모 프로세스 ID를 다시 언급할 것이다. real과 effective user와 group ID는 섹션 4.4에서 논의한 것이다.

8.3 fork 함수


유닉스 커널에의해 새 프로세스가 생성되는 유일한 방법이 이미 존재하는 프로세스가 fork 함수를 호출하는 것이다. (이것은 우리가 이전 장에서 언급한 특별한 프로세스에는 적용되지 않는다. 이 프로세스들은 커널이 bootstrapping 과정의 일부로 생성한다.)
#include
#include
pid_t fork(void);
리턴값: 자식에서는 0, 부모에서는 자식 프로세스의 ID, -1이면 에러
fork에 의해 생성되는 새로운 프로세슷는 자식 프로세스라 부른다. 이 함수는 한번 호출되고 두번 리턴한다. 그 리턴들간의 유일한 차이점은 자식 프로세스에서는 0을 리턴하고, 부모 프로세스에서는 새로운 자식의 프로세스 ID를 리턴한다는 것이다. 자식 프로세스의 프로세스 ID는 부모에게 리턴되는 이유는, 프로세스가 여러개의 자식 프로세스를 만들 수 있기 때문에, 어떤 함수도 그 프로세스의 자식 프로세스의 ID를 리턴해 주지는 않기 때문이다. fork가 자식 프로세스에서 0을 리턴하는 이유는 프로세스가 단지 1개의 부모 프로세스를 가질 수 있기 때문이며, 자식은 항상 getppid를 호출하여 그 부모의 프로세스 ID를 얻을 수 있기 때문이다.(프로세스 ID 0은 항상 swapper에 의해 사용되므로, 이것이 자식의 프로세스 ID가 0이 될 수는 없다.)
자식과 부모는 모두 fork의 호출에 이어지는 명령들을 실행한다. 자식은 부모를 그대로 복사한 것이다. 예를 들어, 자식은 부모의 데이터 공간, 힙, 스택을 복사한다. 이것이 자식에게 '복사'된다는 사실에 주의하라. 부모와 자식은 메모리상의 어떤 공간도 공유하지 않는다. 만일 텍스트 세그먼트가 읽기 전용일 경우, 때때로 부모와 자식은 그것을 공유할 수는 있다.(섹션 7.6)
현재의 많은 유닉스 구현들은 부모의 데이터, 스택, 힙을 완벽히 복사하지는 않는다. 왜냐하면 fork는 대게 exec로 이어지기 때문이다. 대신, copy-on-write(COW)라 불려지는 기술이 사용된다. 이 공간들은 무모와 자식에의해 공유되며, 커널에의해 읽기 전용으로 바뀐다. 만일 프로세스가 이 영역을 변경하려고 하면, 커널은 메모리의 그 부분--대게 가상 메모리 시스템에서의 "페이지"만 복사를 한다. Leffler et al[1989]의 Bach[1986] 섹션 9.2와 섹션 5.7에서 이것에 대해 더 자세히 다룬다.
예제
#include
#include "ourhdr.h"
int glob = 6;
char buf[] = "a write to stdoutn";
int main(void)
{
int var;
pid_t pid;
var = 88;
if(write(STDOUT_FILENO, buf, sizeof(buf)-1) !=sizeof(buf)-1) err_sys("write error");
printf("before forkn");
if((pid=fork())<0) err_sys("fork error"); else if(pid==0) { glob++; var++; } else sleep(2); printf("pid = %d, glob = %d, var = %dn", getpid(), glob, var); exit(0); } 프로그램 8.1은 fork 함수를 설명하는 것이다. 이 프로그램을 실행하면 다음과 같은 결과를 얻는다. $a.out a write to stdout before fork pid=430, glob=7, var=89 pid=429, glob=6, var=88 $a.out > temp.out
a write to stdout
before fork
pid=430, glob=7, var=89
before fork
pid=431, glob=6, var=88
일반적으로, 우리는 자식이 부모가 실행하기 전에 실행하는가 아니면 그 반대인가 알지 못하였다. 이것은 커널에의해 사용되는 스케줄링 알고리즘에 따라 달라진다. 만일 자식과 부모가 각각 동기화될 필요가 있다면, 어떤 종류의 프로세스 내부의 통신이 필요하다. 프로그램 8.1에서 우리는 부모에게 2초동안 sleep 하도록 하고, 자식을 실행한다. 이것이 적합할것이라는 보장은 없고, 우리는 이것과 다른 종류의 동기화들에 대해서는 섹션 8.8에서 race 상태에 대해 다룰 때 이야기 할 것이다. 섹션 10.16에서, 우리는 fork 이후에 부모와 자식간의 동기화에 어떻게 시그널을 이용하는지를 살펴볼 것이다.
프로그램 8.1에서 fork와 I/O 함수들간의 상호작용에 주의하기 바란다. 3장을 상기해 볼 때, write 함수는 버퍼화되지 않는다. write 함수가 fork 이전에 불리기 때문에, 이 데이터는 표준 출력에 한번만 출력된다. 표준 IO 라이브러리는 그러나 버퍼화된다. 섹션 5.12에서 표준 출력은 마닝ㄹ 터미널 장치와 연결되면 라인 버퍼화되고 아닌 경우에는 완전 버퍼화된다고 했었다. 우리가 프로그램을 대화식으로 실행할 때, 우리는 단 한개의 printf만을 가진다. 왜냐하면 표준 출력은 새 줄을 만나 flush 되기 때문이다. 그러나 우리가 표준 출력을 파일로 리다이렉트하면 두 개의 printf를 가지게 된다. 이 경우 printf가 fork 이전에 한번 호출되고, 그 줄은 fork가 호출될 때 까지 버퍼 내부에 머물러 있게 된다. 이 버퍼가 부모의 데이터 영역이 자식에게로 복사될 때 자식에게로 복사된다. 부모와 자식 모두가 이제 표준 IO 버퍼에 이 줄을 가지게 된다. 두번째 exit 바로 직전에 호출되는 printf는 그 데이터를 존재하는 버퍼의 뒤에 덧붙인다. 각 프로세스가 종료하면, 이 버퍼의 복사본은 마침애 flush된다.
파일 공유
프로그램 8.1에서 언급할만한 또다른 점은 우리가 부모의 표준 출력을 리다이렉트 시킴면, 자식의 표준 출력도 또한 리다이렉트 된다는 것이다. 사실, fork의 한가지 특성은 부모의 모든 기술자가 자식으로 복제된다는 것이다. '복제(duplicated)'라는 말은 dup 함수가 각각의 기술자에 대해 호출된것과 비슷하다는 것이다. 부모와 자식은 모든 열려진 기술자에 대해 g하나의 파일 테이블을 공유한다.(그림 3.4를 상기해 보라)
프로세스가 표준 출력, 표준 입력, 표준 에러라는 세개의 파일을 연다는 것을 생각해 보라. fork로부터 리턴될 때, 우리는 그림 8.1과 같은 배치를 가질 것이다.
부모와 자식이 같은 파일 옵셋을 가진다는 사실은 중요하다. 자식을 fork하고 자식이 완전히 마칠 때 까지 기다리는 프로세스를 생각해 보라. 두 프로세스가 표준 출력을 일반적으로 처리한다고 가정하자. 이 경우 자식 윈도우는 부모가 기다리는 동안 표준 출력에 쓸 것이고, 자식이 마친 이후에는 부모가 표준 출력에 쓸 것며, 자식이 무엇을 썼던 간에 그것에 이어서 쓰게 됨을 알 수 있다. 만일 부모와 자식이 파일 옵셋을 공유하지 않는다면, 이러한 종류의 상호작용은 부모 프로세스의 명시적인 작업을 요구하며 훨씬 더 어려워 질 것이다.
만일 부모와 자식이 (예를 들어 부모가 자식을 wait 한다던가하는) 어떤 종류의 동기화도 없이 같은 기술자를 사용한다면, (그 기술자는 fork 하기 이전에 열려있었다고 가정할 때) 그 출력은 서로 섞일 것이다. 이것이 가능하지만(우리는 이것을 프로그램 8.1에서 보았다.) 이것은 일반적인 경우의 작업이 아니다.
fork 이후의 기술자를 다루기 위한 두개의 일반적인 경우가 있다.
부모가 자식이 끝나기를 기다린다. 이 경우 부모는 그 기술자들을 사용하여 어떠한 작업도 하지 않는다. 자식이 종료하면, 자식이 읽거나 쓴 파일의 기술자들은 파일 옵셋이 업데이트 되었을 것이다.
부모와 자식은 각각의 길을 간다. 이 경우 fork가 일어난 이후, 부모는 필요하지 않은 파일을 닫고, 자식도 같은 일을 한다. 이 방법은 각자 다른 사람의 열려진 기술자에 대해서는 아무 일도 하지 않는다. 이 시나리오는 때때로 네트워크 서버에서 사용된다.
열려진 파일 이외에, 부모 프로셋의 속성이 자식으로 상속되는 다양한 것들이 있다.
real user ID, real group ID, effective user ID, effective group ID
추가적인 그룹 ID
프로세스 그룹 ID
세션 ID
제어 터미널
set-user-ID 플래그와 set-group-ID 플래그
현재 작업 디렉토리
루트 디렉토리
파일 모드 생성 마스크
시그널 마스크와 배치
열려진 파일 기술자들의 closee-on-exec 플래그
환경
첨부된 공유 메모리 세그먼트
자원 한계
부모와 자식간에 서로 다른 차이점들은 다음과 같다.
fork로부터 리턴된 값
프로세스 ID가 다르다.
두 프로세스의 부모 프로세스 ID--자식 프로세스의 부모 프로세스 ID는 부모가 될 것이고, 부모 프로세스의 부모는 변함 없을 것이다.
자식 프로세스의 tms_utime, tms_stime, tms_cutime, tms_ustime 값은 0으로 세팅된다.
부모에의한 파일 잠금은 상속되지 않는다.
예약되어진 알람은 자식에서 지워진다.
예약되어진 시그널 집합은 자식에서 빈 집합으로 바뀐다.
이 것들 중 많은 것은 아직 논의되지 않은 것이다. 루니는 그것들을 다음 장에서 다룰 것이다.
fork가 실패하는 두가지 이유는 (a)만일 시스템에 너무 많은 프로세스가 있거나(이것은 대게 다른 어떤 문제가 있음을 의미한다) (b)real user ID의 프로세스의 총 갯수가 시스템의 제한을 넘긴 경우이다. 그림 2.7에서 CHILD_MAX가 real user ID당 동시에 생성할 수 있는 프로세스의 갯수의 최대값임을 상기해 보라.
fork를 사용하는 두 가지 경우가 있다.
프로세스가 그 자신을 복제하여 부모와 자식이 각각 같은 코드의 다른 부분을 동시에 수행할 때. 이것은 네트워크 서버의 경우 일반적이다. 부모는 클라이언트의 요청을 기다린다. 요청이 들어오면 부모는 fork 하고 자식이 그 요청을 다룬다. 부모는 다시 다음 서비스 요청이 들어오기를 기다린다.
프로세스가 다른 프로그램을 실행하고자 할 때. 이것은 쉘에서 일반적이다. 이 경우 자식은 fork가 리턴한 직후 exec(섹션 8.9에서 설명 될 것이다.)를 실행한다.
어떤 운영 체제에서는 2단계의 작업(fork와 이어지는 exec)을 spawn이라 불리는 하나의 작업으로 합치기도 한다. 유닉스는 이 둘을 구분하며, exec 없는 fork를 많이 사용한다. 또한, 이 둘을 구분함으로써, 자식은 fork와 exec 사이에서 IO 리다이렉션이나 user ID, 시그널 배치, 등등 프로세스 단위의 속성을 변경할 수 있다. 우리는 이것의 다양한 예제를 14장에서 살펴볼 것이다.

8.4 vfork 함수

vfork 함수는 fork를 호출한 것과 똑같은 일을 하며, 같은 리턴값을 가진다. 그러나 이 두 함수의 의미는 다르다.
vfork는 4BSD의 초기 가상 메모리에서 기원하였다. Leffler et al[1989]의 섹션 5.7에서 "비록 이것이 극도로 효율적이지만, vfork는 특별한 의미를 가지고 있고, 일반적으로 아키텍처상의 결함을 고려한다."라고 말하고 있다.
SVR4와 4.3+BSD는 vfork를 지원한다.
어떤 시스템에서 헤더를 가지고 있고, vfork를 호출할 때 include 되어야 한다.
vfork는 새로운 프로세스가 새 프로그램을 exec하기 위해 새로운 프로세스를 만들 때 사용되는 경향이 있다.(이전 섹션의 끝 부분의 두번째 경우) 프로그램 1.5에서 작성한 쉘의 골격이 이런 종류의 프로그램의 예제이다. vfork는, 마치 fork처럼, 새로운 프로세스를 생성하지만, 자식이 그 주소공간을 참조하지 않을 경우--자식이 vfork 후 바로 exec(혹은 exit)를 호출할 경우-- 부모의 주소공간을 전부 자식에게 복사하지는 않는다. 대신, 자식이 실행중인 동안, exec나 exit를 호출하기 전에, 자식은 부모의 주소공간에서 실행된다. 이러한 최적화는 일부 유닉스 구현에서 가상 메모리 페이지의 효율성을 높이기 위해 제공되어다.(우리가 이전 섹션에서 언급했듯이, fork 후 이어지는 exec의 효율성을 높이기 위해 어떤 유닉스 구현은 copy-on-wirte을 제공하기도 한다.)
이 두 함수의 또 다른 다른점은 vfork의 경우, 자식에서 exec나 exit를 호출할 때 까지, 먼저 자식이 호출된다는 것이다. 자식이 이 두 함수 중 하나를 호출하면 부모가 복귀된다.(이것은 자식이 이 두 함수중 하나를 호출하기 전에 부모의 다음 단계를 필요로 한다면 프로그램이 죽을 수 도 있게 만든다.)
예제
프로그램 8.1에서 fork를 호출하는 부분을 vfork로 바꾸어라. 우리는 표준 출력으로 write하는 부분을 삭제하였다. 또한 자식이 exec나 exit를 호출할 때 까지 커널에의해 부모는 sleep 상태로 들어가므로 sleep을 호출할 필요가 없어졌다.
#include
#include "ourhdr.h"
int glob = 6;
int main(void)
{
int var;
pid_t pid;
var = 88;
printf("before vforkn");
if((pid=vfork())<0) err_sys("vfork error"); else if(pid==0) { glob++; var++; _exit(0); } printf("pid = %d, glob = %d, var = %dn", getpid(), glob, var); exit(0); } 이 프로그램을 실행하면 다음과 같다. $a.out before vfork pid = 607, glob = 7, var = 89 자식이 변수를 증가시키자 부모의 변수값도 변화되었다. 자식 프로세스가 부모 프로세스의 주소 공간에서 실행되기 때문에 이것은 그리 놀라운 것이 아니다. 이것은 그러나, fork 와는 분명히 다른 것이다. 프로그램 8.2에서 exit 대신 _exit가 사용되었음에 유의하라. 우리가 섹션 8.5에서 논의하였듯이, _exit는 표준 IO 버퍼의 어떤 flush도 수행하지 않는다. 만일 우리가 exit를 대신 호출하면, 출력 결과는 다음과 같이 달라질 것이다. $ a.out before vfork 이 결과는 부모의 printf가 사라졌다! 이것은 자식이 exit를 호출하고, 이것이 모든 IO 스트림을 flush 하기 때문에 일어난다. 이것은 표준 출력을 포함한다. 심지어 이것이 자식에의해 일어났지만, 이것은 부모의 주소 공간에서 일어난다. 그러므로 변경된 모든 IO FILE 객체는 부모에의해 변경된다. 부모가 printf를 나중에 부르면, 표준 출력이 닫혀진 상태이므로 printf는 -1을 리턴한다. Leffler et al[1989]의 섹션 5.7에서는 fork와 vfork의 구현에 대한 추가적인 정보가 제공된다. 예제 8.1과 8.2는 vfork에 대한 이 논의를 이어간다.

8.5 exit 함수들

 우리가 섹션 7.3에서 논의하였듯이 프로세스가 정상적으로 종료하는 세가지 방법과 비 정상적으로 종료하는 두가지 방법이 있다.

정상적인 종료 
main에서 return을 실행한다. 섹션 7.3에서 보듯이 이것은 exit를 호출하는 것과 동치이다. exit 함수를 호출한다. 이 함수는 ANSI C에서 정의된 것으로, atexit를 호출하여 등록한 모든 종료 핸들러를 부르고, 모든 IO 스트림을 닫는다. ANSI C는 파일 기술자, 멀티 프로세스(자식과 부모), 작업 제어등을 다루지 않기 때문에 유닉스에서 이 함수의 정의는 완벽하지 못하다. _exit 함수를 호출한다. 이 함수는 exit 함수에의해 호출되고, 유닉스 특유의 세부 사항을 다룬다. _exit는 POSIX.1에서 지정하고 있다. 대부분의 유닉스 구현에서 exit(3)은 표준 C 라이브러리에 있고, _exit(2)는 시스템 호출이다.

비정상적인 종료
 abort를 호출한다. 이것이 SIGABRT 시그널을 발생하기 때문에 다음 것의 특별한 경우이다. 프로세스가 특정 시그널을 받았을 때(우리는 시그널에 대해 10장에서 더 자세히 논의한다.) 시그널은 프로세스 그 자신에 의해(예를들어 abort 함수를 호출하면), 다른 프로세스에 의해, 혹은 커널에 의해 발생할 수 있다. 커널에의해 생성되는 시그널의 예는 프로세스가 자신의 주소 공간 내부가 아닌 다른 공간을 참조하거나 0으로 나누었을 경우이다. 어떻게 프로세스가 종료하는가를 고려하지 않는다면, 커널에 있는 같은 코드가 마침내 실행된다. 이 커널 코드는 프로세스에의해 열려진 모든 기술자를 닫고, 그것이 사용하고 있던 메모리를 해제하는 것과 같은 일을 한다. 이전에 말한 어떤 경우든 우리는 부모 프로세스에게 어떨게 종료했는지를 알리고 싶을 것이다. exit와 _exit 함수의 경우, 이것은 종료 상태를 두 함수의 인자로 넘기면 된다. 비정상적인 종료의 경우, 그러나, 커널(프로세스가 아니라)이 비 정상적으로 종료한 까닭을 가리키는 소멸 코드를 생성해 준다. 어떤 경우든, 부모 프로세스는 종료 코드를 wait이나 waitpid 함수로부터 얻을 수 있다.(다음 섹션에서 논의된다.) 우리가 "종료 상태"(exitsk _exit 혹은 main의 리턴값으로 넘겨진 값)와 소멸 상태를 구분하고 있음을 주의하라. 종료 상태는 _exit가 마침내 호출 될 때 커널에의해 소멸 상태로 변환된다.(그림 7.1을 상기해 보라) 그림 8.2는 부모가 자식의 소멸 상태를 조사하는 방법을 설명한다. 만일 자식이 정상적으로 종료하였다면, 부모는 자식의 종료 코드를 얻어올 수 있다. 우리가 fork 함수를 설명할 때, 자식이 fork 이후 부모 프로세스를 가진다는 것은 약간 모호했다. 이제 우리는 부모 클래스로 소멸 코드를 넘기는 것에 대해 이야기 하고 있다. 그러나 자식보다 부모가 먼저 종료를 하게 되면 어떻게 되는가? 그 답은 init 프로세스가 부모가 종료한 모든 프로세스의 부모가 된다는 것이다. 우리는 그 프로세스가 init으로 상속되어진다고 말한다. 늘상 일어나는 일은 어떤 프로세스가 종료할 때 마다 커널이 모든 활성화된 프로세스를 돌면서 종료하는 프로세스가 아직 살아있는 어떤 프로세스의 부모인가를 살펴보는 것이다. 만일 그러면, 이미 존재하는 프로세스의 부모 프로세스 ID는 1(init의 프로세스 ID)로 바뀌어진다. 이것은 모든 프로세스의 부모가 존재함을 보장해 준다. 우리가 걱정할 수 있는 또다른 경우는 자식이 부모보다 먼저 종료하는 경우이다. 만일 부모가 마지막으로 자식이 종료하였는지를 채크하려고 할 때, 만일 자식이 완전히 사라졌으면, 부모는 그 소멸 코드를 얻어올 수 없다는 것이다. 해답은 커널이 종료하는 프로세스의 일정 양의 정보를 모두 저장하고 있다가 종료하는 프로세스의 부모가 wait이나 waitpid를 호출할 때 그 정보를 사용할 수 있도록 해 주는 것이다. 최소한 이 정보는 프로세스 ID와 그 프로세스의 소멸 코드, 그 프로세스에의해 사용된 CPU 시간 정도는 포함하여야 한다. 커널은 그 프로세스에 의해 사용되던 메모리는 버리고, 열려진 파일을 닫을 수는 있다. 유닉스 용어집에서는 '소멸했지만, 그 부모는 아직 그것을 wait하지 않은 경우' 그것을 좀비(zombie)라고 부른다. ps(1) 명령은 좀비 프로세스의 상태를 Z로 표시한다. 만일 우리가 많은 자식 프로세스를 fork하고, 그 프로세스들의 소멸 코드를 가져오기 위해 wait을 하지 않는 오랫동안 실행되는 프로그램을 작성한다면, 그것들은 좀비가 될 것이다. System V는 좀비를 피하기 위한 비표준적인 방법을 지원한다. 이것은 섹션 10.7에서 설명할 것이다. 우리가 고려할 마지막 상태는 다음과 같다. init에게 상속된 프로세스의 경우는 어떠한가? 이것은 좀비가 되는가? 대답은 "no"이다. 왜냐하면 init은 그렇게 쓰여졌기 때문이다. 그 자식이 소멸할 경우, init은 소멸 상태를 가져오기 위해 wait 함수들을 호출한다. 이렇게 함으로써, init은 시스템이 좀비들에의해 찐득찐득 달라붙게되지 않도록 만든다. 우리가 "init의 자식중 하나"라고 말할 때, 우리는 init이 직접 생성한 프로세스를 말하기도 하고(섹션 9.2에서 설명할 getty와 같은), 부모가 소멸하여 init에의해 상속되어진 프로세스를 말하기도 한다.

8.6 wait과 waitpid 함수들

 프로세스가 소멸할 때, 정상적이건 비정상적이건, 커널이 부모에게 SIGCHLD 시그널을 보냄으로써 그 부모가 알게 된다. 자식의 소멸이 비 동기화된 이벤트이기 때문에(이것은 부모가 실행중일 때도 발생할 수 있기 때문에) 이 시그널은 커널로부터 부모로 보내지는 비동기화된 알림이다. 부모는 이 시그널을 무시할 수 도 있고, 시그널이 발생했을 때 호출되는 함수를 (시그널 핸들러) 작성해 줄 수 도 있다. 이 시그널에 대한 기본 동작은 시그널을 무시하는 것이다. 우리는 이 옵션을 10장에서 논의한다. 지금은 wait이나 waitpid를 호출한 프로세스에서 다음이 가능하다는 것에 대해 알 필요가 있다. 블록(만일 모든 자식이 여전히 실행중이라면) 또는 자식의 소멸 상태와 함께 즉시 리턴하는 경우(만일 자식이 이미 소멸했고, 소멸 상태가 가져가 지기를 기다리는 경우), 혹은 즉시 에러와 함께 리턴되는 경우(어떤 자식 프로세스도 없는 경우) 만일 그 프로세스가 wait을 호출하면 이것이 SIGCHLD 시그널을 받았기 때문에, 우리는 wait이 즉시 리턴할 것이라는 것을 알 수 있다. 그러나 우리가 그것을 임의의 시간에 호출하였다면, 그것은 블록될 수도 있다. #include #include pid_t wait(int *statlog); pid_t waitpid(pid_t pid, int *statlog, int options); 둘다 리턴: 성공하면 프로세스 ID, 0(이후 참조), -1이면 에러 이 두 함수의 차이점은 wait은 자식 프로세스가 소멸할 때까지 호출자를 블록할 수 있다. waitpid는 이러한 블로킹을 막는 옵션이 있다. waitpid는 첫번째 자식이 종료할 때까지 기다리지 않을 수 있다.--이것은 어떤 경우에 기다릴 지에 대한 수 많은 옵션들을 가지고 있다. 만일 자식이 이미 소멸하였고, 좀비가 되었으면, wait은 즉시 자식의 상태를 리턴한다. 아니면 이것은 자식이 종료할 때 까지 호출자를 블록한다. 만일 호출자가 블록되엇고, 많은 자식을 가지고 있으면, wait은 그 중 하나가 소멸하면 리턴한다. 우리는 어떤 자식이 소멸했는지를 리턴된 프로세스 ID를 보고 알아낼 수 있다. 두 함수 모두 statloc 인자는 정수 포인터 형이다. 만일 이 인자가 널포인터가 아니면, 소멸된 프로세스의 소멸 상태가 그 포인터가 가리키는 곳에 저장된다. 만일 우리가 소멸 상태에 대해 관심이 없으면 이 인자로 널 포인터를 넘기면 된다. 전통적으로 이 함수가 리턴하는 정수형 상태는 특정 비트가 종료 상태(정상적인 리턴)로 정의되었고, 다른 비트는 시그널 번호를 가리키도록(비 정상적인 리턴), 그리고 한 비트가 코어 파일이 생성되었는가를 가리키는 것 등등으로 정의되었다. POSIX.1은 소멸 상태는 에 정의된 다양한 매크로를 사용하여 살펴봐야 한다고 적고 있다. 그 중 서로간에 배타적인 세개의 매크로는 우리에게 프로세스가 어떻게 소멸하였는가를 말해주며, 그들은 WIF로 시작한다. 이 세개 중 어느 값이 참인가에 따라, 다른 매크로가 종료 상태, 시그널 번호, 등등을 얻기 위해 사용된다. 이 것들은 그림 8.2에 나타나 있다. 우리는 섹션 9.8에서 작업 제어를 논의할 때 어떻게 프로세스가 종료하는가를 다룰 것이다. 예제 프로그램 8.3에 있는 함수 pr_exit는 그림 8.2의 매크로들을 사용하여 소멸 상태에 대한 설명을 출력해 준다. 우리는 이 함수를 이 책의 다양한 프로그램에서 부를 것이다. 이 함수는 만일 선언되어 있다면 WCOREDUMP 메크로를 사용한다는 사실에 주의하라. 프로그램 8.4는 pr_exit 함수를 부르고, 소멸 상태를 위한 다양한 값들을 설명해 준다. 만일 프로그램 8.4를 실행하면 다음과 같을 것이다. $a.out normal termination, exit status = 7 abnormal termination, signal number = 6 (core file generated) abnormal termination, signal number = 8 (core file generated) #include #include #include "ourhdr.h" void pr_exit(int status) { if(WIFEXITED(status)) printf("normal termination, exit satus = %dn", WEXITSTATUS(status)); else if(WIFSIGNALED(status)) printf("abnormal termination, signal number = %d%sn", WTERMSIG(status), #ifdef WCOREDUMP WCOREDUMP(status)?"(core file generated)":""); #else ""); #endif else if(WIFSTOPPED(status)) printf("child stopped, signal number = %dn",WSTOPSIG(status)); } 불행히도, WTERMSIG의 시그널 번호로부터 그 이름을 얻어오는 이식 가능한 어떠한 대응도 찾을 수 없다.(섹션 10.21을 보면 함수 하나를 볼 수 있다) 우리는 헤더를 보고, SIGABRT가 6이라는 값을 가지고, SIGFPE가 8이라는 값을 가진다는것을 확인하는 수 밖에 없다. 이미 언급했듯이, 만일 한개 이상의 자식을 가지면, wait은 어떤 자식이 소멸하더라도 리턴한다. 우리가 어떤 프로세스가 소멸하기를 기다리기 위해서는(우리가 기다리는 그 프로세스의 프로세스 ID를 알고 있다고 가정하면)어떻게 해야하는가? 예전 버전의 유닉스에서는 wait을 호출하고 리턴되는 프로세스 ID가 우리가 관심있는 프로세스 ID인가 비교하는 방법을 사용하였다. 만일 소멸한 프로세스가 우리가 원하는 것이 아니면, 우리는 소멸한 프로세스 소멸 상태를 기록하고 다시 wait 함수를 호출한다. 우리는 이것을 원하는 프로세스가 소멸할 때 까지 계속한다. 다음번에 우리가 특정한 프로세스가 종료했는가를 알아보기 위해서는 우리는 이미 종료한 프로세스의 리스트로 돌아가 우리가 기다릴 프로세스가 이미 종료했는가를 살펴보고, wait을 다시 호출한다. 우리가 특정한 하나의 프로세스만을 기다린다면, 이 기능은(그리고 더 많은 기능들이) POSIX.1의 waitpid 함수에서 제공된다. #include #include #include "ourhdr.h" int main(void) { pid_t pid; int status; if((pid=fork())<0) err_sys("fork error"); else if(pid==0) exit(7); if(wait(&status)!=pid) err_sys("wait error"); pr_exit(status); if((pid=fork())<0) err_sys("fork error"); else if(pid==0) abort(); if(wait(&status)!=pid) err_sys("wait error"); pr_exit(status); exit(0); } waitpid 함수는 pid 인자를 다음과 같이 해석한다. pid==-1 임의의 자식 프로세스를 기다린다. 이 경우 waitpid는 wait과 동일하다. pid>0 프로세스 ID가 pid인 자식을 기다린다.
pid==0 호출하는 프로세스와 프로세스 그룹 ID가 같은 자식을 기다린다.
pid<-1 프로세스 그룹 ID가 pid의 절대값과 같은 자식을 기다린다. (우리는 프로세스 그룹을 섹션 9.4에서 논의할 것이다.) waitpid는 소멸한 프로세스 ID를 리턴하고, 소멸 상태는 statloc을 통해 넘어온다. wait이 에러를 발생하는 경우는 프로세스가 자식을 갖지 않을 경우 뿐이다.(이 함수가 시그널에의해 방해를 받으면 다른 에러가 리턴이 가능하다. 우리는 이것을 10장에서 다룬다) waitpid는 그러나 또한 주어진 프로세스나 프로세스 그룹이 존재하지 않거나 그 프로세스의 자식 프로세스가 아닌 경우에도 에러를 리턴한다. options 인자는 waitpid가 하는 작업을 컨트롤하게 해 준다. 이 인자는 0이거나 그림 8.3에 있는 상수들을 비트 OR한 값일 수 있다. SVR4에서 두개의 추가적인, 그러나 비표준인, option 상수가 지원된다. WNOWAIT은 시스템으로 하여금 waitpid에 의해 리턴되는 소멸 상태를 저장하고 있게 해 준다. 이후에 이것은 다시 wait 될 수 있다. WCONTINUED는 pid가 가리키는 자식 프로세스가 계속 되고 있고, 상태가 아직 보고되지 않았다고 하더라도, 그 상태를 리턴하도록 한다. waitpid 함수는 wait 함수에의해 제공되지 않는 세개의 요소를 제공해 준다. (wait이 임의의 소멸하는 자식에대해 리턴하는 반면에) waitpid는 한개의 특별한 프로세스를 기다리도록 해 준다. waitpid는 wait의 블로킹되지 않는 버전을 지원해 준다. 우리가 자식의 상태를 알아볼 필요는 있지만, 블로킹되고 싶지는 않을 때가 있을 것이다. waitpid는 (WUNTRACED 옵션과 함께 쓰여서) 작업 제어를 지원한다. 섹션 8.5에서 좀비에 관한 우리의 논의를 상기해 보라. 우리가 프로세스를 하나 만들고 싶어서 fork를 하였지만, 자식이 작업을 끝낼 때 까지 블록킹당해서 기다리기도 싫고, 우리가 종료할 때 까지 자식이 좀비가 되는것도 원하지 않을 때, 하나의 트릭은 fork를 두번하는 것이다. 프로그램 8.5가 이것을 보여준다. #include #include #include "ourhdr.h" int main(void) { pid_t pid; if((pid=fork())<0) err_sys("fork error"); else if(pid==0) { if((pid=fork())<0) err_sys("fork error"); else if(pid>0) exit(0); // 두번째 fork의 부모==첫번째 자식
sleep(2);
printf("second child, parent pid = %dn", getppid());
exit(0);
}
if(waitpid(pid, NULL, 0)!=pid) err_sys("waitpid error");
exit(0);
}
우리는 두번째 자식에서 sleep을 호출하였다. 이것은 인쇄를 하기 전에 첫번째 자식이 완전히 종료하였음을 보장해 준다. fork 이후에 부모 혹은 자식이 누군가는 실행을 계속할 것이다. --우리는 결코 누가 먼저 실행을 재개할지는 알 수 없다. 만일 우리가 두번째 자식을 sleep 하지 않으면, fork 이 후 이것이 자신의 부모보다 먼저 실행 될수도 있다. 이 경우 그 프로세스의 부모 프로세스의 ID는 (프로세스 ID 1이 아니라) 첫번째 자식이 될 것이다.
프로그램 8.5를 실행하면 결과는
$a.out
$second child, parent pid = 1
두번째 자식이 부모 프로세스를 인쇄하기 전에 본래 프로그램이 종료하자, 쉘이 프롬프트를 출력하였음에 유의하라.

8.7 wait3과 wait4 함수

4,3+BSD는 두개의 추가적인 함수, wait3와 wait4를 지원해 준다. POSIX.1 함수 wait과 waitpid가 제공하지 못하고 이 두 함수들에 의해서만 지원되는 것은 종료한 프로세스와 그의 모든 자식 프로세스가 사용하던 자원의 총 합을 커널로부터 리턴받을 수 있는 인자가 있다는 것이다.
#include
#include
#include
#include
pid_t wait3(int *statloc, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *statloc, int options, struct rusage *rusage);
둘다 리턴값: 성공하면 프로세스 ID, 에러면 -1
자원 정보는 사용자 CPU 시간과, 시스템 CPU 시간과, 페이지 결함의 갯수, 받은 시그널의 갯수, 등등과 같은 정보이다. 추가적인 정보를 위해 getrusage(2) 메뉴얼 페이지를 참조하라.
이 자원 정보는 중지된 것이 아니라 소멸한 자식 프로세스에서만 얻어질 수 있다.(이 자원 정보는 우리가 섹션 7.11에서 논의한 자원 한계와는 다른 값이다.) 그림 8.4는 다양한 wait 함수들에 의해 지원되는 인자들을 보여준다.

8.8 경주 상태

경주 상태는 여러개의 프로세스가 공유된 데이터에 작업을 하려고 하며, 최종 출력은 어떤 프로세스가 동작했는가에 따라 달라질 때 발생하게 된다. fork함수는, 만일 fork 이후에 자식이 먼저 실행되는가 부모가 먼저 실행되는가에 따라 결과가 달라지는 논리가 있을 경우에, 실로 살아숨쉬는 경쟁 상태의 번식처라 할 수 있다. 일반적으로 말하면, 우리는 어느 프로세스가 먼저 실행될지 알 수 없다. 심지어 우리가 어떤 프로세스가 먼저 실행되는가를 안다고 하더라도, 프로세스가 실행된 후의 일은 시스템의 로드나 커널의 스케줄링 알고리즘에 따라 달라진다.
우리는 프로그램 8.5에서 자식 프로세스가 그 부모 프로세스의 ID를 출력할 때, 잠재적인 경쟁상태의 가능성을 볼 수 있다. 만일 두번째 자식이 첫번째 자식 이후에 실행된다면, 이것의 부모 프로세스는 첫번째 프로세스가 될 것이다. 그러나 첫번째 자식이 먼저 실행되고, exit할 충분한 시간이 있다면, 두번째 자식의 부모는 init 프로세스가 될 것이다. 심지어 우리가 한 것 처럼 sleep을 호출한다 하더라도, 보장되는 것은 아무것도 없다. 만일 시스템이 과부하가 걸린다면, 첫번째 자식이 실행할 기회도 받기 전에 두번째 자식은 잠에서 깨어날 것이다. 이런 형식의 문제점은 "대부분의 경우" 아주 잘 동작하기 때문에 디버깅하기가 매우 힘들다.
만일 프로세스가 자식이 소멸하기를 기다린다면, wait 함수를 불러야 한다. 만일 프로세스가 프로그램 8.5에서 처럼 부모가 소멸하기를 기다린다면 다음과 같은 루프를 돌아야 한다.
while(getppid()!=1) sleep(1);
이런 종류의 루프(폴링이라 불린다)를 사용함의 문제점은, 호출한 곳에서 상태를 테스트하는 동안 계속 깨어있어야 하므로 CPU 타임의 낭비를 초래한다는 것이다.
경주 상태를 피하고 폴링도 피하기 위해서, 다중 프로세스 간의 시그널이 필요하다. 이 문제에서도 시그널이 사용될 수 있고, 우리는 이 방법을 섹션 10.16에서 다룰 것이다. 다양한 형태의 프로세스간 통신(IPC)가 사용된다. 우리는 이중 일부를 14장과 15장에서 다룰 것이다.
부모와 자식간의 관계에서, 우리는 자주 다음과 같은 각본을 따르게 된다. fork 이후 부모와 자식 둘다 어떤 일을 한다. 예를 들어, 부모는 자식의 프로세스 ID를 로그 파일에 남기려고 하고, 자식은 부모에 대해 로그 파일을 만들려고 한다. 이 예제에서 우리는 각 프로세스가 상대에게 언제 자신의 작업이 초기화가 끝났는지를 알려주고, 자신이 일을 하기 전에 서로 상대방이 끝날 때를 기다린다. 이러한 각본은
#include "ourhdr.h"
TELL_WAIT();
if((pid=fork())<0) err_sys("fork error"); else if(pid==0) { TELL_PARENT(getppid()); WAIT_PARENT(); exit(0); } TELL_CHILD(pid); WAIT_CHILD(); exit(0); } 우리는 ourhdr.h가 다양한 번수들을 선언하였다고 가정하였다. 다섯개의 루틴 TELL_WAIT, TELL_POARENT, TELL_CHILD, WAIT_PARENT, WAIT_CHILD는 매크로일 수 도 있고, 실재 함수일 수 도 있다. 우리는 TELL과 WAIT 루틴의 다양한 구현을 다음 장에서 다룰 것이다. 섹션 10.16에서 시그널을 사용하여 그 구현을 보여줄 것이다. 프로그램 14.3은 스트림 파이프를 사용하여 이것을 구현할 것이다. 이 다섯개의 루틴을 사용하는 예제를 보자. 예제 프로그램 8.6은 하나는 자식으로부터, 하나는 부모로부터 두 문자열을 출력한다. 이것은 커널에의해 어떤 순서로 프로세스가 실행되었는가, 그리고 그 프로세스가 수행되는데 얼마나 시간이 걸렸는가에 따라 출력이 달라지므로 경주 상태를 포함한다. #include #include "ourhdr.h" static void charatatime(char *); int main(void) { pid_t pid; if((pid=fork())<0) err_sys("fork error"); else if(pid==0) charatatime("output from childn"); else charatatime("output from parentn"); exit(0); } static void charatatime(char *str) { char *ptr; int c; setbuf(stdout, NULL); for(ptr = str; c = *ptr++;) putc(c, stdout); } 우리는 표준 출력을 버퍼화되지 않도록 하고, 한글자 한글자가 모두 write을 생성한다. 이 예제의 목적은 가능한 커널이 두 프로세스 사이를 왔다 갔다 하도록 하여 경주 상태를 설명하기 위한 것이다.(만일 우리가 이렇게 하지 않는다면 결코 이제 볼 결과를 볼 수 없을 것이다. 에러가 있는 듯한 출력 결과를 보지 못한다고 해서 경주 상태가 존재하지 않아 지는 것은 아니다. 이것은 우리가 특정 시스템에서 이것을 보지 못한다는 것만을 의미하는 것이다.) 다음 실재 결과는 얼마나 결과가 다양해 질 수 있는가를 보여준다. $a.out output from child output from parent $a.out oouuttppuutt ffrroomm cphairledn t $a.out oouuttppuutt ffrroomm pcahrielndt $a.out ooutput from parent utput from child 우리는 프로그램 8.6을 TELL과 WAIT을 사용하도록 바꾸었다. 프로그램 8.7이 그것이다. 플러스 기호가 있는 줄이 새로 추가된 것이다. #include #include "ourhdr.h" static void charatatime(char *); int main(void) { pid_t pid; + TELL_WAIT(); + if((pid=fork())<0) err_sys("fork error"); else if(pid==0) { + WAIT_PARENT(); charatatime("output from childn"); } else { charatatime("output from parentn"); + TELL_CHILD(pid); } exit(0); } static void charatatime(char *str) { char *ptr; int c; setbuf(stdout, NULL); for(ptr = str; c = *ptr++;) putc(c, stdout); } 우리가 이 프로그램을 실행하면 출력은 우리가 예상했던 대로--두 프로세스로부터 온 문자열이 어떠한 섞임도 없이 출력될 것이다. 프로그램 8.7은 부모가 먼저 실행한다. 만일 fork 이후의 줄들을 다음과 같이 바꾸면 else if(pid==0) { charatatime("output from childn"); TELL_PARENT(getppid()); } else { WAIT_CHILD(); charatatime("output from parentn"); } 자식이 먼저 실행된다. 연습문제 8.3은 이 예제를 이어갈 것이다.

8.9 exec 함수들

 우리는 섹션 8.3에서 fork의 한가지 예로 새로운 프로세스(자식)를 생성하고, exec 함수를 호출함으로써 다른 프로그램을 실행할 수 있다고 하였다. 프로세스가 exec 함수를 호출하면 프로세스는 새 프로그램으로부터 완전히 분리되며, 새 프로그램이 자신의 main 함수를 호출한다. 새로운 프로세스가 생성되지 않으므로, 프로세스 ID는 exec에 의해 변경되지 않는다. exec는 단지 현재 프로세스(텍스트, 데이터, 힙, 스택 세그먼트)등을 디스크상에 있는 새 프로그램으로 바꾸어 주는 일만 한다. 여섯개의 서로다른 exec 함수가 있다. 우리는 때때로 이것을 단지 "exec 함수"라고 부를 것이다. 이럴 경우 어떤 함수를 사용해도 상관없다. 이 여섯개의 함수들이 유닉스 프로세스 제어의 기본 윤곽이다. fork를 사용하여 우리는 새로운 프로세스를 만들고, exec를 사용하여 새 프로그램을 실행한다. exit 함수와 두개의 wait 함수들은 그것들의 소멸과 소멸을 기다리는 것을 다룬다. 우리에게 필요한 것은 단지 작업 제어의 기본이다. 우리는 이후 섹션에서 popen과 system같은 추가적인 함수를 다루면서 이 기본들을 사용할 것이다. #include int execcl(contst char *pathname, const char *arg0, ...); int execv(const char *pathname, char *const argv[]); int execle(const char *pathname, const char *arg0, ..., char *const envp[]); int execve(const char *pathname, char *const argv[], char *const envp[]); int execlp(const char *filename, const char *arg0, ...); int execvpe(const char *filename, char *const argv[]); 모두 리턴값: 에러면 -1, 성공하면 리턴값 없음 이 함수들의 첫번째 차이점은 첫번째 네개는 인자로 경로명을 받고 나머지 두개는 파일 이름을 받는다는 것이다. filename이 인자로 사용될 때 만일 filename이 슬레쉬를 포함하면, 경로명으로 여겨진다. 다른 경우는 PATH 환경 변수에서 지정하고 있는 디렉토리를 검색한다. PATH 변수는 콜론으로 분리된 디렉토리의 리스트들을 가진다.(이것은 path 첨두어라 불린다) 예를 들어 name=value 환경 문자열에서 PATH = /bin:/usr/bin:/usr/local:/usr/local/bin/:. 은 네개의 디렉토리를 검색하도록 한다.(길이가 0인 첨두어는 현재 작업 디렉토리를 말한다. 이것은 value의 시작이나 끝에 콜론이 나오거나 중간에 두개의 콜론이 나오는 것을 말한다.) 보안상의 이유로, 현재 디렉토리는 검색 경로에 추가해서는 안된다. Garfinkel & Spafford[1991]을 참조하라. 만일 execlp나 execvp 두 함수중의 하나가 경로명에서 실행 파일을 찾았으나, 파일이 링크 에디터에의해 생성된 실행 파일이 아니면 이것은 쉘 스크립트로 간주되어 /bin/sh을 불러 filename을 쉘의 입력으로 넣는다. 이 함수들의 또 다른 차이점은 인자 리스트를 사용하는가이다(l은 리스트, v는 벡터를 나타낸다). 함수 execl, execlp, execle는 새 프로그램의 명령행 인자를 분리된 인자로 지정한다. 우리는 그 인자의 끝을 널 포인터로 넣어주어 표시한다. 다른 세개의 함수(execv, execvp, execve)에서 우리는 인자를 위한 배열을 만들고 그 배열의 포인터를 넘겨야 한다. ANSI C 프로토타입을 사용하기 전에, 세 개의 함수 execl, execle, execlp에서 명령행 인자를 표시하기 위한 일반적인 방법은 char * arg0, char *arg1, ... , char *argn, (char *)0 이었다. 이것은 널 포인터로 끝나는 유한한 명령행 인자를 의미한다. 만일 널 포인터를 상수 0으로 넣어주려면, 우리는 명시적으로 형변환을 해야했다. 그렇지 않으면 이것은 정수형으로 여겨졌으며, 만일 정수형은 char *의 형과 다른 사이즈를 가지고 있다면 exec 함수의 실제 인자는 잘못된 값이 될 것이다. 마지막 차이점은 새 프로그램으로 넘어가는 환경 리스트이다. 이름이 e로 끝나는 두 함수는 환경 문자열의 포인터가 저장된 배열의 포인터를 넘긴다. 다른 네개의 함수는 그러나 environ 변수를 이용하여 현재 프로세스로부터 새 프로그램이 실행되는 프로세스로 환경을 복사한다.(섹션 7.9와 그림 7.5에서 환경 문자열에 대한 우리의 논의를 상기해 보라. 우리는 시스템이 setenv와 putenv와 같은 함수를 지원할 경우 현재 환경과 모든 자식 프로세스에 적용될 환경을 변경할 수 있다고 하였다. 그러나 우리는 부모 프로세스의 환경에는 영향을 미칠 수 없다고 하였다.) 대게 프로세스는 자신의 환경을 자식에게 전파시키지만, 프로세스가 자식이 특정한 환경을 사용하도록 할 수 있다. 그것의 한 예는 lonin 프로그램에서 새 로그인 쉘이 초기화될 때 이다. 대게 로그인은 우리가 로그인할 때, 선언된 몇개의 변수와 함께 우리가 쉘 시작 파일에서 추가한 변수들을 환경에 지정한다. ANSI C 프로토타입을 사용하기 전에는 execle의 인자는 다음과 같았다. char * arg0, char *arg1, ... , char *argn, (char *)0, char *envp[] 이것은 마지막 인자가 환경 문자열들을 가리키는 배열의 주소라는 것을 나타낸다. ANSI C의 프로토타입은 이렇게 나타내지 않고, 모든 명령행 인자, 널 포인터, 환경 변수 포인터를 생략 부호 ...으로 나타낸다. 이 여섯개의 exec 함수들은 기억하기 어렵다. 함수의 이름에 있는 문자들은 그러나 이것을 약간 도와준다. 문자 p는 filename 인자가 실행 파일을 찾기 위해 PAHT 환경 변수를 사용한다는 것을 나타낸다. 문자 l은 인자가 리스트, v는 인자가 argv[] 벡터라는 것을 나타낸다. 마지막으로 문자 e는 그 함수가 현재 환경 대신에 envp[] 배열을 받는다는 것을 나타낸다. 그림 8.5는 이 여섯개의 함수들의 차이점을 보여준다. 모든 시스템은 환경 리스트와 인자의 총 갯수에 한계를 가지고 있다. 그림 2.7에서 이 한계는 ARG_MAX이다. 이 값은 POSIX.1 시스템에서 적어도 4096 바이트가 되어야 한다. 우리가 파일 이름의 리스트를 생성할 때 쉘의 파일 이름 확장을 사용하면 때때로 이 한계를 넘길 것이다. 예를 들어, 명령 grep _POSIX_SOURCE /usr/include /*/*.h 는 어떤 시스템에서 다음과 같은 쉘 에러를 발생시킨다. arg list too long 역사적으로 System V에서 한계 값은 5120 바이트였고, 4.3BSD와 4.3+BSD에서 이 값은 20,480 바이트로 불어났다. 필자에의해 사용된 시스템은(프로그램 2.1의 출력을 보아라) 이것을 1메가까지 허용했다. 우리는 프로세스 ID가 exec에의해 변하지 않는다고 말했다. 그러나 호출한 프로세스로부터 새로운 프로그램으로 상속되는 요소들이 더 있다. 프로세스 ID와 부모 프로세스 ID real user ID와 real group ID 추가적인 그룹 ID 프로세스 그룹 ID 세션 ID 제어 터미널 알람이 시각까지 남은 시간 현재 작업 디렉토리 루트 디렉토리 파일 모드 생성 마스크 파일 잠금 프로세스 시그널 마스크 보류된 시그널들 리소스 한계 tms_utime, tms_stime, tms_cutime, tms_ustime 값들 열려진 파일에 대해서는 각 파일 기술자의 close-on-exec 플래그에 따라 달라진다. 섹션 3.13의 그림 3.2에서 우리가 FD_CLOEXEC 플래그에 대해 어떤 프로세스의 모든 열려진 기술자가 close-on-exec 플래그를 가진다고 말했던 것을 상기해 보라. 이 플래그가 세팅되어 있으면, 기술자는 exec시에 닫힌다. 그렇지 않은 기술자는 exec시에 열린채로 남아있는다. 우리가 fcntl함수를 호출하여 close-on-exec 플래그를 세팅하지 않으면 기본값은 exec시에 기술자가 열려진채로 있는 것임을 주의하라. POSIX.1은 열려진 디렉토리 스트림(섹션 4.21의 opendir 함수를 상기해 보라)은 exec시에 닫혀야 한다고 요구하고 있다. 이것은 대게 opendir 함수가 디렉토리 스트림을 열 때 fcntl을 호출하여 관련된 기술자의 close-on-exec 플래그를 세팅함으로 구현된다. real user ID와 real group ID는 exec시에 그대로 임에 유의하라. 그러나 실행되는 프로그램 파일의 set-user-ID와 set-gorup-ID 비트에 따라 effective ID는 바뀔 수 있다. 만일 set-user-ID 비트가 새 프로그램에 세팅되어 있으면 effective user ID는 프로그램 파일의 소유자 ID로 바뀐다. 그렇지 않으면 effective user ID는 변하지 않는다.(이것은 real user ID로 세팅되지 않는다.) 그룹 ID도 같은 방법으로 다루어진다. 많은 유닉스 구현에서 여섯개의 함수중 단지 execve 한개만이 커널 내부에 있는 시스템 호출이다. 다른 다섯개의 함수는 단지 결국에 이 시스템 호출을 이용하는 라이브러리 함수일 뿐이다. 우리는 여섯개의 함수들 간의 관계를 그림 8.6 처럼 그릴 수 있다. 이 배열에서 라이브러리 함수 execlp와 execvp는 filaname으로 지정된 실행 파일을 찾기 위해 PATH 환경 변수를 이용한다. 예제 프로그램 8.8은 exec 함수들을 설명해 준다. #include #include #include "ourhdr.h" char *env_init[] = {"USER=unknown", "PATH=/tmp", NULL}; int main(void) { pid_t pid; if((pid=fork())<0) err_sys("fork error"); else if(pid==0) { if(execle("/home/stevens/bin/echoall", "echoall", "myarg1", "MY ARG2", (char*) 0, env_init)<0) err_sys("ececle error"); } if(waitpid(pid, NULL, 0)<0) err_sys("wait error"); if((pid=fork())<0) err_sys("fork error"); else if(pid==0) { if(execlp("echoall", "echoall", "only 1 arg", (char*) 0)<0) err_sys("ececlp error"); } exit(0); } 우리는 먼저 경로명과 특정한 환경을 지정하는 execle를 호출한다. 다음으로 파일명을 받고 호출자의 환경을 사용하는 execlp를 호출한다. execlp가 작동하는 유일한 이유는 /home/stevesns/bin 디렉토리가 현재 디렉토리 첨두어 중의 하나이기 때문이다. 또한 우리가 새 프로그램에 대해 첫번째 인자, argv[0]를 경로명의 파일이름 부분으로 지정해 주었다는 사실을 주의하라. 어떤 쉘은 이 인자를 완전한 경로명으로 세팅한다. 프로그램 8.8에의해 두번 호출되는 프로그램 echoall은 프로그램 8.9에 나타나 있다. 이것은 모든 명령행 인자와 환경 변수 리트스틀 복사하는 상투적인 프로그램이다. #include "ourhdr.h" int main(int argc, char *argv[]) { int i; char **ptr; extern char **environ; for(i=0; i< i, %sn?, printf(?argv[%d]: i++)>
for(ptr = environ; *ptr!=0; ptr++) printf("%sn", *ptr);
exit(0);
}
프로그램 8.8을 실행하면 다음과 같을 것이다.
$a.out
argv[0]: echoall
argv[1]: myarg1
argv[2]: MY ARG2
USER=unknown
PATH=/tmp
argv[0]: echoall
$argv[1]: only 1 arg
USER=stevens
HOME=/home/stevens
LOGNAME=stevens
...
EDITOR=/user/ucb/vi
두번째 exec가 있은 후 argv[0]과 argv[1] 사이에 쉘 프롬프트가 보여졌음에 주의하라. 이것은 부모가 이 자식 프로세스가 끝내기를 wait하지 않았음을 의미한다.


8.10 사용자 ID와 그룹 ID를 바꾸기


우리는 real user ID와 effective user ID를 setuid 함수를 사용하여 세팅할 수 있다. 유하사게 우리는 real group ID와 effective group ID를 setgid 함수를 사용하여 세팅할 수 있다.
#include
#include
int setuid(uid_t uid);
int setgid(gid_t gid);
둘다 리턴값: 0이면 OK, -1이면 에러
누가 ID를 바꿀 수 있는가에 대한 규칙들이 있다. 일단 사용자 ID에 대해서만 생각해 보자.(우리가 사용자 ID에 대해 설명하는 모든 것은 그룹 ID에도 적용된다)
만일 프로세스가 슈퍼유저 권한을 가지고 있으면, setuid 함수는 real user ID, effective user ID, saved set-user-id를 uid로 바꿀 수 있다.
만일 프로세스가 슈퍼유저 권한을 가지고 있지 않으면, 그러나 uid가 real user ID 또는 saved set-user-ID와 같으면, setuid는 단지 effective user ID를 uid로 바꿀 수 있다. real user ID와 saved set-user-ID는 변하지 않는다.
만일 이 둘 중 아무것도 아니면, errno는 eperm으로 세팅되고 에러가 리턴된다.
여기서 우리는 _POSIX_SAVED_IDS가 참이라고 가정하였다. 만일 이것이 지원되지 않는다면, saved set-user-ID에 대한 위의 모든 언급을 지우면 된다.
PIPS 151-1은 이 것을 요구한다.
SVR4는 _POSIX_SAVED_IDS 구성요소를 지원한다.
우리는 커널이 관리하는 그 세 사용자 ID에 대해 몇가지 언급할 것이 있다.
단지 슈퍼유저 프로세스만이 real user ID를 바꿀 수 있다. 대게 real user ID는 우리가 로그인 할 때 login(1) 프로그램에 의해 세팅되고, 절대 바뀌지 않는다. login은 슈퍼유저 프로세스이기 때문에, 우리는 setuid를 이 세 사용자 ID에 대해 호출할 수 있다.
effective user ID는 exec 함수에 의해 단지 ser-user-ID 비트가 프로그램 파일에의해 세팅되어 있을 때만 세팅된다. 만일 set-user-ID 비트가 세티오디어 있지 않으면, exec 함수는 effective user ID를 현재 값으로 유지한다. 우리는 언제든 setuid를 호출하여 effective user ID와 real user ID 혹은 saved set-user ID를 변경할 수 있다. 물론, 우리는 effective user ID를 임의의 값으로 바꿀 수 는 없다.
saved set-user ID는 ecex에의해 effective user ID를 복사한다. 이 복사는 (마닝 f파일의 set-user-ID 비트가 켜져 있을 경우) exec가 파일의 user ID를 effective user ID로 복사한 후에 실행된다.
그림 8.7은 이 세 user ID를 변경할 수 있는 다른 방법을 요약해 준다.
우리는 getuid와 geteuid 함수를 사용하여 단지 real user ID의 현재 값과 effective user ID만을 얻을 수 있음을 섹션 8.2에서 공부하였다. 우리는 saved-set-user-ID의 현재 값을 얻을 수 없다.
예제
저장된 set-user-ID에 대한 유용성을 알아보기 위해, 그것을 사용하는 프로그램의 작동을 살펴보자. 우리는 버클리 tip(1) 프로그램을 살펴볼 것이다.(System V cu(1) 프로그램이 유사하다) 직접 연결 혹은 모뎀 연결을 통해 두 프로그램 모두 원격 시스템에 접속한다. tip이 모뎀을 사용할 때, 이 프로그램은 잡겨진 파일을 사용하여 모뎀을 독점적으로 사용해야 한다. 이 잠겨진 파일은 또한 UUCP 프로그램에 의해 공유되어야 한다. 왜냐하면 동시에 UUCP 프로그램도 모뎀을 사용하기 때문에이다. 다음 단계가 밟아진다.
1. tip 프로그램이 uucp라는 유저 네임에 의해 소유된 파일이고 set-user-ID 비트가 세팅되어 있다면, 우리가 exec를 호출하면, 다음과 같이 된다.
real user ID=our user ID
effective user ID=uucp
저장된 set-user-ID=uucp
2. tip이 잠겨진 파일에 접근을 필요로 한다. 이 잠겨진 파일은 uucp이라는 사용자 이름에의해 소유되어 있다. 그러나 effective user ID가 uucp이면 파일 접근이 허용된다.
3. tip은 setuid(getuid())를 실행한다. 우리가 슈퍼유저 프로세스가 아니므로, 이것은 단지 effective user ID만일 바꾼다. 그 결과는 다음과 같이 된다.
real user ID=our user ID(불변)
effective user ID=our user ID
저장된 set-user-ID = uucp(불변)
이제 tip 프로세스는 그 effective user ID를 우리의 사용자 ID로 실행되고 있다. 이것은 우리가 단지 우리가 일반적으로 접근할 수 있는 파일만 접근할 수 있다는 것을 의미한다. 우리는 추가적인 권한을 가지지 않는다.
4. 우리가 완료하면, tip은 uucp라는 사용자 이름에 해당하는 숫자로된 사용자 ID uucpuid로 setuid(uucpuid)를 실행한다.(이것은 아마 tip이 geteuid를 호출할 때 저장되어 있었을 것이다.) 이 호출은 setuid의 인자가 저장된 set-user-ID와 같으므로 허용된다.(이것은 우리가 저장된 set-user-ID를 필요로 하는 이유이다.) 그 결과는 다음과 같다.
real user ID=our user ID(불변)
effective user ID=uucp
저장된 set-user-ID = uucp(불변)
5. tip은 이제 effectiv user ID가 uucp이므로 자신의 잠근 파일에 대해 작업-- 이 경우에는 해제를 할 수 있다.
저장된 set-user-ID를 이런식으로 사용함으로써, 우리는 프로그램의 시작할 때와 끝날 때 파일의 set-user-ID에의해 우리에게 허용되는 추가적인 권한을 이용할 수 있다. 프로세스가 수행하는 대부분의 경우, 그것은 우리의 사용자 ID로 실행된다. 만일 우리가 저장된 set-user-ID로 되돌릴 수 없었다면, 우리는 추가적인 권한을 실행하는 동안 계속 유지하여야 할 것이며 이것은 문제를 유발할 수 도 있다.
만일 tip이 실행시에 shell을 만들어낸다고 가정해 보라(쉘은 fork와 exec를 이용하여 만들어진다.) real user ID와 effective user ID가 둘다 우리의 일반적인 ID이므로(위의 3단계의 경우), 쉘은 추가적인 권한을 가지지 않는다. 쉘은 tip이 실행되는 동안 uucp로 세팅된 저장된 set-user ID에 접근할 수 없다. 왜냐하면 exec는 effective user ID를 saved set-user ID로 복사하기 때문이다. 그러므로, 자식 프로세스에서 exec를 하면, 모든 세개의 사용자 ID는 우리의 일반 ID로 세팅된다.
어떻게 setuid 함수가 tip에의해 사용되는가를 설명하는 우리의 설명은 프로그램이 root로 set-user-ID된것이라면 성립하지 않는다. 이것은 슈퍼유저의 권한으로 setuid를 하면, 모든 세개의 사용자 ID를 슈퍼유저로 세팅하기 때문이다. 반면 우리가 언급한 예제에서 우리는 setuid를 사용하여 단지 effective user ID만을 세팅하였다.
setreuid 와 setregid 함수
4.3+BSD에서 setreuid 함수를 사용하여 real user ID와 effective user ID를 바꾸는 것이 지원된다.
#include
#include
int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);
둘다 리턴값: 성공하면 0, 실패하면 -1
규칙은 간단하다. 권한이 없는 사용자는 real user ID와 effective user ID를 바꿀 수 있다. 이것은 set-user-ID 프로그램으로 하여금 사용자의 보통 퍼미션을 바꾸는 것과, 다시 이후의 set-user ID 연산을 위해 되돌리는 것을 허용해 준다. 저장된 set-user ID가 POSIX.1에서 소개 되었을 때, 이 규칙은 권한이 없는 사용자로 하여금 그 effective user ID를 saved set-user-ID로 바꿀 수 있도록 향상되었다.
SVR4 또한 이 두 함수를 BSD 호환 라이브러리에서 지원해 주고 있다.
4.3BSD는 이전에 언급한 저장된 set-user-ID를 가지지 않는다. 이것은 setregid 대신 setreuid를 사용한다. 이것은 권한이 없는 사용자로 하여금 두 값을 바꾸고, 되돌리도록 해 준다. 4.3BSD에서의 tip 프로그램은 이 구성요소를 사용하여 작성되었다. 그러나, 이 버전의 tip이 쉘을 만들어 내면, 이러한 것은 exec 하기 전에 real user ID를 일반 user ID로 바꾼다는 것을 알아두라. 만일 그렇게 하지 않으면, real user ID는 uucp가 될 수 있으므로(setreuid를 호출하여) 쉘 프로세스는 setreuid를 사용하여 두 user ID를 바꾸고, uucp의 권한을 가질 수 있게 된다. tip은 자식을 만들 때 보안적인 차원에서 real user ID와 effective user ID를 모두 일반 user ID로 바꾸게 된다.
seteuid와 setegid 함수
POSIX.1에 제안된 수정은 두 함수 seteuid와 setegid를 포함한다. 단지 effective user ID와 effective group ID만이 변한다.
#include
#include
int seteuid(uid_t uid);
int setegid(gid_t 향);
둘다 리턴: 성공하면 0, 실패하면 -1
권한이 없는 사용자는 effective user ID를 real user ID나 저장된 set-user ID로 바꿀 수 있다. 권한이 있는 사용자는 단지 effective user ID만을 uid로 바꿀 수 있다(이것은 모든 세개의 사용자 ID를 바꾸는 setuid 함수와 다르다.) 이것은 POSIX.1 수정에 제안되어 saved-user-ID가 항상 지원할 것을 요구하도록 만들고 있다.
SVR4와 4.3+BSD는 이 두 함수를 지원한다.
그림 8.8은 우리가 이전 섹션에서 세개의 서로 다른 사용자 ID를 변경하기위해 사용하였던 모든 함수들을 요약해 준다.
그룹 ID
우리가 이 섹션에서 언급한 모든 것들은 같은 식으로 그룹 ID에도 적용된다. 추갖거인 그룹 ID는 setgid 함수에 의해서는 영향을 받지 않는다.
[출처] 8.프로세스 관리|작성자 정환옹

댓글

가장 많이 본 글