프로그램을 실행시키면, OS는 해당 프로그램을 프로세스 형태로 관리함.
프로세스 API: 프로그래머가 프로세스를 생성, 제어, 모니터링할 수 있도록 OS가 제공하는 함수(시스템 콜)들의 집합. Unix 계열 시스템에서는 fork(), exec(), wait() 등이 있다.
fork(): 새로운 프로세스 만들기
- 기능: fork()는 현재 프로세스를 복사하여 "자식 프로세스"를 하나 더 만듦.
- 동작 원리:
호출한 시점에서 프로세스가 두 개로 나뉨.- 부모 프로세스: 기존 프로세스
- 자식 프로세스: 부모의 복사본
- 반환값 차이:
- 부모 프로세스: fork()가 자식 프로세스의 PID(프로세스 ID)를 반환
- 자식 프로세스: fork()가 0을 반환
- 결과:
이렇게 반환값의 차이를 이용해 부모와 자식에서 각각 다른 코드를 실행.
예제 코드:
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// 자식 프로세스 영역
printf("I am the child process!\n");
} else {
// 부모 프로세스 영역
printf("I am the parent process, child pid = %d\n", rc);
}
exec(): 다른 프로그램 실행하기
- 기능: exec() 계열 함수(execvp, execl, execve 등)는 현재 프로세스를 완전히 다른 프로그램으로 대체.
- 사용 시나리오: fork()를 통해 자식을 만들었는데, 그 자식이 부모와 똑같은 프로그램을 실행하는 대신 전혀 다른 프로그램을 실행하고 싶을 때 호출.
- 동작 원리:
- exec()가 성공하면 기존에 로드되어 있던 코드와 데이터가 새로운 프로그램으로 덮어씌어짐.
- exec()는 성공한 뒤 원래 코드로 돌아오지 않으며, 바로 새로운 프로그램의 main()부터 실행.
예제 코드 (자식에서 wc 명령 실행):
int rc = fork();
if (rc == 0) {
// 자식 프로세스
char *args[] = {"wc", "filename.txt", NULL};
execvp(args[0], args);
// 여기까지 도달하지 않음 (exec 성공 시)
fprintf(stderr, "exec failed\n");
exit(1);
}
wait(): 자식 프로세스 종료 대기
- 기능: wait()는 부모 프로세스가 자식 프로세스가 끝날 때까지 기다리게 하는 시스템 콜.
- 사용 이유:
- 부모 프로세스가 자식의 수행 결과를 필요로 할 때
- 자식이 종료되기 전에 부모가 먼저 종료해버리면 자식이 "고아 프로세스"가 될 수 있으므로, 이를 방지하기 위해 사용
- 반환값: 종료한 자식의 PID를 반환(어떤 자식이 끝났는 지 알 수 있음).
예제:
int rc = fork();
if (rc == 0) {
// 자식 프로세스
printf("Child running...\n");
sleep(2); // 예: 2초 후 종료
exit(0);
} else {
// 부모 프로세스
int wc = wait(NULL); // 자식이 끝날 때까지 대기
printf("Child %d finished\n", wc);
}
왜 이렇게 복잡할까? (fork + exec 조합의 이유)
- 유연성 확보:
자식 프로세스를 만든 후 exec()를 호출하기 전까지 환경을 마음대로 설정할 수 있다.
ex) 자식 프로세스에서 파일 디스크립터를 재지정하여 표준 출력이 화면 대신 파일로 가게 하거나, 파이프를 이용해 다른 프로세스와 연동 등등.. - 쉘의 구현:
쉘(shell): 사용자가 입력한 명령어를 실행하는 프로그램.
fork()로 자식을 만든 뒤, 그 자식 안에서 exec()를 호출하여 사용자가 입력한 명령어 실행.
이 과정에서 I/O 재지정, 파이프, 환경 변수 설정 등등 가능..
추가적인 프로세스 관련 API와 개념
- kill(): 특정 프로세스에 시그널을 보내 프로세스를 종료, 중단, 재개하는 등.
- ps, top: 현재 실행 중인 프로세스를 모니터링하고 CPU, 메모리 사용량을 확인.
- nice(): 프로세스의 우선순위를 조정.
- setsid(), setpgid(): 프로세스를 세션이나 프로세스 그룹으로 묶어 제어.
- 파이프(pipe) & 리다이렉션(>), <): 파이프를 통해 한 프로세스의 출력을 다른 프로세스의 입력으로 연결하거나, 명령어 실행 결과를 파일로 저장하는 등 다양한 입출력을 조작.
다른 운영체제와 비교
- Windows:
Windows는 CreateProcess()라는 단일 API 콜로 새로운 프로세스 생성과 프로그램 로딩을 함께 처리. (CreateProcess() 하나로 프로세스 생성 및 실행) - 멀티스레드 환경:
fork()는 호출 시점의 스레드와 메모리 상태를 복사 → 스레드 안전성 문제 fork() 직후 exec()를 바로 호출하는 것이 권장된다.
GPT님님님의 의견
멀티스레드 프로그램에서 fork()를 호출할 경우 다음과 같은 문제가 발생할 수 있습니다:
- 스레드 복제 방식:
fork()는 호출한 스레드를 포함한 전체 프로세스의 메모리 상태를 복사하지만, 실제로 새로 생성된 자식 프로세스에는 호출한 스레드 하나만 존재하게 됩니다. 즉, 부모 프로세스에는 여러 스레드가 있었지만, fork() 후 자식 프로세스에는 단 하나의 스레드(호출한 스레드)만 남게 됩니다. 이 과정에서 다른 스레드들이 잡고 있던 락(lock)이나 공유 자원에 대한 접근 상태, 전역 변수 상태 등은 그대로 복사되지만 정작 그 스레드들은 존재하지 않습니다. 결과적으로 일부 리소스나 락이 "해제 불가능한 상태"가 되거나 비정상적인 동기화 상태에 빠질 수 있습니다. - 동기화 상태 불일치:
멀티스레드 프로그램은 락, 조건 변수, 뮤텍스, 세마포어 등의 동기화 기법으로 스레드 간 자원 접근을 조율합니다. fork()를 통해 복사된 자식 프로세스는 메모리 상에서 동기화 객체의 상태를 그대로 물려받지만, 실제로 락을 잡고 있던 스레드는 사라졌으므로 해당 락은 유령 상태(잠긴 상태에서 더는 풀 수 없는 상태)가 될 수 있습니다. 또한 조건 변수가 특정 스레드를 깨우기 위한 신호를 기다리고 있는 상태일 때, 그 스레드는 더 이상 존재하지 않으므로 조건을 충족할 수 없는 교착 상태가 발생할 수 있습니다. - 해결책 – fork() 후 즉시 exec():
이러한 문제를 피하기 위해, 표준 C 라이브러리와 POSIX 표준에서는 멀티스레드 환경에서 fork()를 호출한 뒤에는 가능한 한 빠르게 exec() 계열 함수를 호출하여 완전히 새로운 프로그램 이미지로 전환하는 것을 권장합니다. exec()를 호출하면 기존 메모리 공간(및 그 안에서 뒤틀린 동기화 상태)을 모두 버리고 새 프로그램의 깨끗한 메모리 상태로 시작하기 때문에 스레드 안전성 문제를 근본적으로 해소할 수 있습니다.
정리하자면, 멀티스레드 환경에서 fork()를 호출하면 부모 프로세스의 복잡한 스레드 및 동기화 상태가 그대로 복사되지만 실제로 자식 프로세스에는 한 스레드만 남기 때문에, 동기화 객체와 공유 자원 상태가 비정상적이고 예측 불가능한 상황에 놓일 수 있습니다. 이를 피하기 위해 자식 프로세스에서 곧바로 exec()를 호출하여 새 프로그램 이미지를 로드하는 것이 안전한 패턴입니다.
'CS' 카테고리의 다른 글
[CS] 페이징: 더 빠른 주소 변환을 위한 TLB 기법 (3) | 2024.12.22 |
---|---|
[CS] 멀티프로세서 스케줄링(Multiprocessor Scheduling) (0) | 2024.12.16 |
[CS] 자바 실행 과정 및 JVM (1) | 2024.12.08 |
[CS] 단일프로세서 시스템(Single Processor System) (1) | 2024.12.02 |
[CS] 자바의 메모리 구조, Static, 그리고 main() 메서드 (2) | 2024.11.03 |