Using fork and execve to Run Programs
Unix 쉘, 웹 서버 같은 프로그램은 fork와 execve 함수를 많이 사용한다.
쉘은 사용자를 대신하여 다른 프로그램을 실행하는 대화형 응용 프로그램이다.
원래 쉘은 sh 프로그램이었다. 그 뒤에 csh, tcsh, ksh, bash 등의 다양한 프로그램이 생겼다.
쉘은 일련의 read/evaluate 과정을 수행한 뒤 종료된다.
read 과정에서 사용자로부터 명령어를 읽어 들인다.
eavluate 과정에서 명령어를 해석하고 사용자를 대신하여 프로그램을 실행한다.
#include "csapp.h"
#define MAXARGS 128
/* Function prototypes */
void eval(char *cmdline);
int parseline(char *buf, char **argv);
int builtin_command(char **argv);
int main()
{
char cmdline[MAXLINE]; /* Command line */
while (1)
{
/* Read */
printf("> ");
Fgets(cmdline, MAXLINE, stdin);
if (feof(stdin))
exit(0);
/* Evaluate */
eval(cmdline);
}
}
위 코드는 쉘의 단순한 과정을 보여준다.
- 쉘은 command-line을 prompt에 출력하고
- 사용자가 stdin에서 command line을 입력할 때까지 기다린 후
- command line을 evaluate 한다.
/* eval - Evaluate a command line */
void eval(char *cmdline)
{
char *argv[MAXARGS]; /* Argument list execve() */
char buf[MAXLINE]; /* Holds modified command line */
int bg; /* Should the job run in bg or fg? */
pid_t pid; /* Process id */
strcpy(buf, cmdline);
bg = parseline(buf, argv);
if (argv[0] == NULL)
return; /* Ignore empty lines */
if (!builtin_command(argv))
{
if ((pid = Fork()) == 0)
{ /* Child runs user job */
if (execve(argv[0], argv, environ) < 0)
{
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}
/* Parent waits for foreground job to terminate */
if (!bg)
{
int status;
if (waitpid(pid, &status, 0) < 0)
unix_error("waitfg: waitpid error");
}
else
printf("%d %s", pid, cmdline);
}
return;
}
/* If first arg is a builtin command, run it and return true */
int builtin_command(char **argv)
{
if (!strcmp(argv[0], "quit")) /* quit command */
exit(0);
if (!strcmp(argv[0], "&")) /* Ignore singleton & */
return 1;
return 0; /* Not a builtin command */
}
위 코드는 command line을 evaluate 하는 코드다.
첫 번째 작업은 parseline 함수를 호출하는 것이다.
/* parseline - Parse the command line and build the argv array */
int parseline(char *buf, char **argv)
{
char *delim; /* Points to first space delimiter */
int argc; /* Number of args */
int bg; /* Background job? */
buf[strlen(buf) - 1] = ’ ’; /* Replace trailing ’\n’ with space */
while (*buf && (*buf == ’ ’)) /* Ignore leading spaces */
buf++;
/* Build the argv list */
argc = 0;
while ((delim = strchr(buf, ’ ’)))
{
argv[argc++] = buf;
*delim = ’\0’;
buf = delim + 1;
while (*buf && (*buf == ’ ’)) /* Ignore spaces */
buf++;
}
argv[argc] = NULL;
if (argc == 0) /* Ignore blank line */
return 1;
/* Should the job run in the background? */
if ((bg = (*argv[argc - 1] == ’&’)) != 0)
argv[--argc] = NULL;
return bg;
}
- 공백으로 구분된 command line argument를 해석하고 execve에 전달될 argv 벡터를 build 한다.
- execve 함수의 첫 번째 argument는 바로 interpret 되는 쉘에 내장된 명령어의 이름 또는 새로운 자식 프로세스의 context에서 로드되어 실행 가능한 개체 파일 중 하나로 간주한다.
- 마지막 argument가 '&'이면 parseline 함수는 1을 반환한다.
반환 값 1 은 프로그램이 background에서 실행되어야 하는 것을 나타낸다.
(쉘은 프로그램이 완료될 때까지 기다리지 않는다.) - 마지막 argument가 '&'가 아니면 프로그램이 foreground에서 실행되어야 함을 나타내는 0이 반환된다.
command line을 해석하고 eval 함수는 builtin_command 함수를 호출하여 첫 번째 command line argument가 쉘의 내장 명령어 인지 여부를 확인한다.
쉘의 내장 명령어라면 즉시 해석하고 1을 반환한다. 그렇지 않으면 0이 반환된다.
이 단순한 쉘은 내장 명령어가 quit 하나밖에 없다.
실제 쉘에는 pwd, jobs, fg 등 다양한 명령어가 있다.
builtin_command가 0을 반환하면 자식 프로세스를 생성하고 자식 내부에서 요청하는 프로그램을 실행한다.
사용자가 background에서 프로그램을 실행하도록 요청하면
쉘은 loop의 가장 위로 돌아가 다음 command line을 기다린다.
forefround에서 프로그램을 실행하도록 요청되면
쉘은 waitpid 함수를 사용하여 작업이 종료될 때까지 기다린다.
작업이 종료되면 쉘은 다음 반복으로 넘어간다.
이 단순한 쉘 프로그램에는 background 자식 프로세스들을 reap하지 않는 문제가 있다.
그렇게 되면 background jobs은 쉘이 종료될 때 좀비 프로세스가 될 것이다.
좀비 프로세스가 되어 메모리 누수가 발생하여 커널의 메모리가 부족해질 수 있다.
background 프로세스가 완료되면 커널이 주기적으로 process를 interrupt 하여 경고를 보낸다.
이와 비슷하게 이러한 문제를 해결하려면 signal을 사용해야 한다.
ls -al &을 입력하면
부모 프로세스는 다음 커맨드를 받아도 된다는 것이고 자식 프로세스는 백그라운드를 돌고 있다는 것이다.
부모 프로세스가 자식 프로세스가 끝날 때 까지 기다리지 않는다.
부모 프로세스도 실행 중인데 자식 프로세스도 동시에 실행 중이면서
부모 프로세스는 다음 커맨드를 기다린다. 자식 프로세스는 언제 끝나는지 모른다.
나중에 자식 프로세스가 끝나면 자식 프로세스가 부모 프로세스한테 끝났으니 reaping 해달라는 메카니즘이 있어야 한다. 그 메카니즘을 signal이라 한다.
Inter-Process Communication
> ls -al | tail -5
예로 들어 ls -al | tail -5라는 명령어를 쉘에 입력하면,
쉘은 부모 프로세스고, 자식 프로세스 두 개를 fork 하여 생성한다.
자식 프로세스는 각각 ls -al, tail -5라는 명령어를 실행할 프로세스이다.
쉘을 fork 하면 자식 프로세스는 부모가 가지고 있던 주소 공간의 복사본을 가지고 있다.
하지만 자식 프로세스의 주소 공간은 private 하기 때문에 서로 통신하는 방법이 없으면 공유하는 공간이 없다.
그래서 프로세스 사이에 Inter-Process Commuication(IPC)을 한다.
IPC는 프로세스 사이에 통신할 수 있는 메커니즘이다.
- 다른 프로세스는 다른 주소 공간에서 실행된다.
- OS가 프로세스 사이의 통신을 제공해줘야 한다.
Ordinary Pipe
IPC 중의 하나가 Pipe이다.
Pipe가 생성되면 프로세스들이 Pipe를 공유한다.
Pipe는 프로세스에서 생성되는 것이 아니라 Kernel 내부에 생성되기 때문에 프로세스 사이에 공유가 가능하다.
Ordinary Pipe는 하나의 프로세스에서 다른 프로세스를 연결하는 단방향 byte stream이다.
- 데이터가 한쪽 끝에서 써지면 다른 쪽 끝에서 읽는 구조.
- 논리적인 관점에서 Pipe는 FIFO queue의 특성을 가짐.
- 데이터가 전달되는 구조를 가지고 있지 않다.
데이터를 보낼 때 크기를 알 수 없다.
sender와 receiver가 Pipe를 가지고 있다. - Pipe는 File Descriptor를 통해 Read와 Write 함으로써 접근할 수 있다.
pipe() 함수가 성공적으로 호출되었으면 0, 실패했을 경우 -1을 반환한다.
Pipe Used by Commands
> ps -aux | grep root | tail
ps -aux의 결과가 grep root의 입력으로 들어오고 그 결과가 tail의 입력이 된다.
쉘이 자식 프로세스 3개를 생성하여 각 프로세스들이 Pipe를 통하여 통신할 수 있다.
Use of Pipe - Example
두 프로세스가 만약 통신한다고 하면 파이프를 하나 만들어야 한다.
#include <stdio.h>
#include <unistd.h>
int main(void) {
int n, fd[2], pid;
char line[100];
if (pipe(fd) < 0)
exit(-1);
if ((pid = fork()) < 0)
exit(-1);
else if (pid > 0) { /* parent */
close(fd[0]);
write(fd[1], "Hello World\n", 12);
wait(NULL);
}
else { /* child */
close(fd[1]);
n = read(fd[0], line, MAXLINE);
write(STDOUT_FILENO, line, n);
}
}
main 함수에서 pipe를 만들고 fork를 한다.
File Descriptor는 각각의 프로세스들이 동일하게 복제되어 가지고 있다.
Pipe를 보면 부모 프로세스의 fd [0]은 Input, fd [1]은 output이다.
자식 프로세스의 fd[0]은 output, fd[1]은 input이다.
부모 프로세스 입장에서 output이 Pipe로 들어가야 하고
자식 프로세스 입장에서 Pipe의 Input을 통해서 읽는다.
각 프로세스에서 사용하지 않는 File Descriptor를 close 해준다.
부모 프로세스는 Hello World라는 메시지를 자식 프로세스에게 전달하고 싶다.
write함수를 통해 File Descriptor를 argument로 하여 호출하면 Pipe에 데이터를 넣는 것이다.
그다음 부모 프로세스는 자식 프로세스가 종료될 때까지 wait 한다.
자식 프로세스는 read를 통해서 pipe를 읽는다.
읽게 되면 STDOUT_FILENO 매크로 상수를 argument로 write함수를 호출하여 Hello world를 출력한다.
그렇게 자식 프로세스가 종료되면 부모 프로세스한테 signal을 보내서 정상 종료를 하게 된다.