fork()
아래는 argument passing 코드를 짜기 전에 혼자 공부했던 내용이다. 이번 장에서는 fork()와 관련된 내용을 다룰 것이기 때문에 접어놓겠다.
fork()
GitBook에 있는 fork()를 참고하자.
pid_t fork (const char *thread_name);
현재 프로세스의 복제본으로 새로운 프로세스를 생성.(해당 프로세스의 이름은 인자로 들어가는 THREAD_NAME). %RBX, %RSP, %RBP, %R12 - %R15(callee-saved register)를 제외한 나머지 레지스터값은 복제할 필요가 없다. 반드시 자식 프로세스의 process id를 반환해야 한다. 그렇지 않으면 유효한 pid가 아니다. 자식 프로세스에서, 리턴값은 반드시 0이어야 한다. 자식 프로세스는 반드시 부모 프로세스로부터 파일 디스크립터, 가상 메모리 공간 등을 포함한 자원을 복사해와야 한다.
부모 프로세스는 자식 프로세스가 성공적으로 복제된 것을 알고 나서 fork로부터 리턴해야 한다. 만약 자식 프로세스가 자원을 복제하는데 실패하면 부모 프로세스의 fork() 호출은 반드시 TID_ERROR를 반환해야 한다. 이 템플릿은 threads/mmu.c 내에 있는 pml4_for_each() 를 사용해 전체 user memory 공간을 복제하는데, 대응하는 페이지 테이블 구조체를 포함한다. 하지만 빈 부분(pte_for_each_func에서)을 채워야 한다.
실행 중인 프로세스(부모)로부터 새로운 프로세스(자식)를 복제하는 함수이다. fork()를 호출하면 PCB를 포함한 부모 프로세스 영역 대부분이 복사된 똑같은 프로세스가 생성되는데, PCB 내용 중 아래 부분들은 변경된다.
- PID: 프로세스의 식별 번호라고 생각하면 간단하다.
- 메모리 관련 정보: 부모 프로세스와 자식 프로세스가 차지하고 있는 메모리의 위치가 다르기 때문
- PPID와 CPID: 자식 프로세스는 부모 프로세스를 가리키는 부모 프로세스 구분자(PPID)가 바뀌고, 만들어진 자식 프로세스는 아직 새로운 자식 프로세스가 없으므로 자식 프로세스 구분자(CPID)의 값이 -1 이다.
현재 PINTOS에서 함수들이 흘러가는 경로
syscall_handler ▶ sys_fork() ▶ process_fork() ▶ thread_create() ▶ __do_fork()__do_fork()는 부모 프로세스의 내용을 자식 프로세스로 복사하는 함수.유저 프로그램의 실행 정보는 syscall_handler로 전달되는 intr_frame에 저장되는데, 이것을 process_fork()를 거쳐 __do_fork로 넘겨주는 방식이다.
자식 프로세스를 생성해야하는 이유는 무엇인가?
1. 프로세스의 생성 속도가 빠르다. 하드디스크로부터 프로그램을 새로 가져오는 것 대신 기존 메모리에 있는 것을 복사하기 때문에 생성 속도가 빠르다. 2. 추가 작업 없이 자원을 상속할 수 있다. 부모 프로세스가 파일 f를 사용하기 위해 초기화를 하는 작업을 진행했다면, 자식 프로세스는 그런 작업 없이 바로 파일 f를 사용할 수 있다. 3. 시스템을 효율적으로 관리할 수 있다. 부모 프로세스와 자식 프로세스가 PPID와 CPID로 연결되어 있기 때문에 자식 프로세스를 종료하면 자식이 사용하던 자원을 부모 프로세스가 관리할 수 있게 된다. 프로세스가 종료될 때 해당 프로세스가 사용하던 메모리 영역, 파일, 하드웨어 등을 제대로 정리하는 것이 중요한데, 이런 작업을 부모 프로세스에게 맡김으로써 시스템이 효율적으로 관리되도록 한다.
fork()와 exec()의 차이
fork(): 새로운 프로세스를 복사exec(): 프로세스를 복사하는 과정 없이 내용만 바꾸는 과정 : 새로운 프로세스를 만들기 위해서는 PCB를 만들고, 메모리의 자리를 새로 확보하는 과정이 필요하다. 또한 프로세스를 종료한 뒤 메모리 청소를 위해 상위 프로세스와 부모-자식 관계를 만드는 과정 역시 필요하다. 이런 과정을 생략하기 위해 프로세스의 구조체를 재활용 하는 것에 목적이 있으며, exec()을 실행하면 현재의 프로세스가 완전 다른 프로세스로 전환된다. 이미 만들어져있는 PCB, 메모리 영역, 부모-자식 관계를 그대로 활용하면서 새로운 코드 영역만 가져와 수월하게 작업할 수 있다.
fork()를 구현하는 중 semaphore는 왜 필요할까?
부모 프로세스는 thread_create의 반환 값으로 받은 tid를 이용해 자식 프로세스를 찾는다. 이후 해당 자식의 child_semaphore를 sema_down()한다. 이 과정은 자식 프로세스의 정상적인 load를 위한 것이다, __do_fork()를 통해 자식 프로세스에 부모 프로세스의 정보를 모두 복사한 뒤 sema_up()을 호출해 세마포어를 해제한다.
semaphore란?
공유되는 자원에 여러 개의 프로세스가 동시에 접근하면 오류가 발생하는데, 공유된 자원 속의 하나의 데이터는 한 번에 하나의 프로세스만 접근할 수 있도록 제한해야 한다. 세마포어는 이를 위해 고안된 것.
현재 pintos
process 구조체에 부모와 자식 관계를 명시하는 부분이 존재하지 않아 부모와 자식의 구분이 없다. 자식 프로세스의 정보를 알지 못하기 때문에 자식의 시작과 종료 전에 부모 프로세스가 종료되는 현상이 발생해 프로그램이 실행되지 않는 경우가 있다. 이를 해결하기 위해 fork() 함수를 사용해야 한다.
1. init.c에서 main() -> run_actions()에서 현재 프로세스가 'run'이면 run_task() 호출.
// ../threads/init.c
static void run_task (char **argv) {
...
process_wait (process_create_initd (task));
...
}
2. process_create_initd() -> thread_create() -> initd 호출
// ../userprog/process.c
tid_t process_create_initd (const char *file_name) {
...
tid = thread_create (file_name, PRI_DEFAULT, initd, fn_copy);
...
return tid;
}
3. initd() -> process_exec() 호출, 새로 생성된 스레드(자식 프로세스) 실행
// ../userprog/process.c
static void initd (void *f_name) {
...
if (process_exec (f_name) < 0)
...
}
4. 자식 프로세스의 실행이 종료되면, wait 중이던 부모 프로세스가 다시 실행된다.
구상
1. proecss_fork
- 리턴 값: 자식 프로세스의 pid
- __do_fork()를 호출하기 전에 자식 프로세스를 만들어주기
- 부모 프로세스(thread_current)의 intr_frame를 가지고 thread_create()를 호출해 __do_fork() 실행
- TID_ERROR 처리해주기
- 자식 프로세스를 sema_down()
2. __do_fork
- 부모의 page table을 복제하기 위해 page table을 생성해야하는데, 이 과정을 위해 duplicate_pte 함수를 구현해야한다.
2-1. duplicate_pte()
(1) If the parent_page is kernel page, then return immediately.
(2) Resolve VA from the parent's page map level 4.
parent_page = pml4_get_page (parent->pml4, va);
(3) Allocate new PAL_USER page for the child and set result to NEWPAGE.
(4) Duplicate parent's page to the new page and check whether parent's page is writable or not (set WRITABLE according to the result).
(5) Add new page to child's page table at address VA with WRITABLE permission.
(6) If fail to insert page, do error handling.
2-2 __do_fork()
자식 프로세스가 생성되면 부모 프로세스와 동일한 fd 테이블이 생성된다. 만약 부모 프로세스가 file A와 B를 open해서 관리하고 있었으면, 자식 프로세스도 똑같이 file A와 B를 open해서 관리하고 있을텐데, 이는 두 테이블의 동일한 위치에서 file A, B를 참고하고 있는 것이다. 이것은 하나의 open file table과 맵핑되어 있고, 컴퓨터 전체적으로 보았을 때 file A, B가 열려있는 횟수(refcnt)는 2이다.
자식 프로세스의 fd테이블은 부모의 것과 동일해야하기 때문에 부모의 fdt 페이지 배열에서 값을 하나씩 복사해 넣어줘야한다. 이 과정에서 fork()로 인해 refcnt는 1씩 증가해야한다. 제공되는 file_duplicate()함수를 이용하자.
구현
- struct thread
// ../include/threads/thread.h
struct thread {
#ifdef USERPROG
...
bool is_exited;
bool is_loaded;
int exit_status;
struct file** fd_table;
int index;
struct intr_frame fork_intr_frame;
struct semaphore child_semaphore;
struct thread *parent;
struct list child_list;
struct list_elem child_elem;
struct file *running_f;
...
#endif
}
해당 내용들은 ../threads/thread.c의 thread_create() 내부 #ifdef USERPROG와 #endif 사이에서 init 해주었음.
- syscall handler에서 sys_fork() 호출
// ../userprog/syscall.c
void syscall_handler (struct intr_frame *f UNUSED) {
uint64_t sysnum = f -> R.rax; // 스택에서 시스템콜 넘버 받아오기
void *rsp = f -> rsp; // intr_frame의 스택 포인터
uint64_t argv = f -> R.rsi; //load 마지막에서 rsi에 argv[[0] = 함수 이름 저장했었음
uint64_t argc = f -> R.rdi; // load 마지막에서 rdi에 argument 개수 저장했었음
if(!is_user_vaddr(rsp))
exit(-1);
switch(sysnum){
...
case SYS_FORK:
f -> R.rax = sys_fork(f -> R.rdi, f);
break;
...
}
}
- sys_fork() 내부에서 process_fork()를 호출하므로 process_fork()로 가자.
// ../userprog/syscall.c
pid_t sys_fork(const char *thread_name, struct intr_frame* f){
lock_acquire(&syscall_lock);
tid_t result = process_fork(thread_name, f);
lock_release(&syscall_lock);
return result;
}
- process_fork()
: Clones the current process as 'name'. Returns the new process's thread id, or TID_ERROR if the thread can't be created
: __do_fork에 intr_frame을 전달하기 위해서 현재 스레드의 fork_intr_frame에 if_를 복사해 전달한다.
// ../userprog/process.c
tid_t process_fork (const char *name, struct intr_frame *if_ UNUSED) {
/* Clone current thread to new thread.*/
// return thread_create (name,
// PRI_DEFAULT, __do_fork, thread_current ());
struct thread *parent = thread_current();
memcpy(&parent -> fork_intr_frame, if_, sizeof(struct intr_frame));
tid_t child_tid = thread_create(name, PRI_DEFAULT, __do_fork, parent);
if (child_tid == TID_ERROR)
return -1;
sema_down(&child_semaphore);
return child_tid;
}
- duplicate_pte()
// ../userprog/process.c
static bool duplicate_pte (uint64_t *pte, void *va, void *aux) {
struct thread *current = thread_current ();
struct thread *parent = (struct thread *) aux;
void *parent_page;
void *newpage;
bool writable;
/* 1. TODO: If the parent_page is kernel page, then return immediately. */
if(is_kernel_vaddr(va))
return true;
/* 2. Resolve VA from the parent's page map level 4. */
parent_page = pml4_get_page (parent->pml4, va);
if (parent_page == NULL)
return false;
/* 3. TODO: Allocate new PAL_USER page for the child and set result to
* TODO: NEWPAGE. */
newpage = palloc_get_page(PAL_USER);
/* 4. TODO: Duplicate parent's page to the new page and
* TODO: check whether parent's page is writable or not (set WRITABLE
* TODO: according to the result). */
memcpy(newpage, parent_page, PGSIZE);
writable = is_writable(pte);
/* 5. Add new page to child's page table at address VA with WRITABLE
* permission. */
if (!pml4_set_page (current->pml4, va, newpage, writable)) {
palloc_free_page(newpage);
current -> exit_statue = -1;
return false;
/* 6. TODO: if fail to insert page, do error handling. */
}
return true;
}
- __do_fork()
static void
__do_fork (void *aux) {
struct intr_frame if_;
struct thread *parent = (struct thread *) aux;
struct thread *current = thread_current ();
...
/* TODO: Your code goes here.
* TODO: Hint) To duplicate the file object, use `file_duplicate`
* TODO: in include/filesys/file.h. Note that parent should not return
* TODO: from the fork() until this function successfully duplicates
* TODO: the resources of parent.*/
current -> fd_table[0] = parent -> fd_table[0];
current -> fd_table[1] = parent -> fd_table[1];
for (int i = 2; i < parent -> index; i++){
struct file *f = parent -> fd_table[i];
if (f == NULL)
break;
current -> fd_table[i] = file_duplicate(f);
}
current -> index = parent -> index;
process_init ();
/* Finally, switch to the newly created process. */
sema_up(&parent -> child_semaphore);
if (succ){
if_.R.rax = 0;
do_iret (&if_);
}
error:
sema_up(&parent -> child_semaphore);
thread_exit ();
}
process.c에 있는 함수들 중 내가 건드려야할 것들을 간단히 살펴보자.
1. process의 생성 과정
process.c의 process_execute()는 전달받은 커맨드라인을 실행할 파일명과 그 외의 argument들로 parsing하고, 해당 파일을 실행하기 위해 stack에 필요한 값들을 load한다(argument passing에서 구현한 내용). 나는 parsing과 stack에 변수를 저장하는 과정 모두 process.c의 load() 함수 내부에서 처리했다. process_execute()가 새 스레드를 생성하는 것에 성공하면, 그 스레드를 자신의 child_list에 추가하고, 해당 스레드의 process 구조체를 초기화해줌으로써 새로운 process를 생성한다.
thread 생성이 끝난 뒤에는 start_process()가 종료될 때까지 기다리며, 종료된 뒤에 새로운 프로세스가 성공적으로 실행했는지를 exit_status를 통해 에러 메세지를 반환할지, tid를 반환할지에 따라 확인할 수 있다. 종료될 때까지 기다리는 과정에서 semaphore를 이용한다.
start_process()는 load가 성공할 시에 stack에 argument들을 쌓으며, load된 executable file에 다른 값이 덮어씌워지지 않는 것을 보장하기 위해 file_deny_write 함수를 호출한다. 작업이 완료되면 sema_up()을 통해 부모 스레드에게 자식 스레드의 실행이 완료된 것을 알린다. load 성공 시 load된 파일을 실행하고, 실패 시 exit(-1)한다.
2. process_wait
부모가 기다리고자 하는 자식 스레드의 프로세스 구조체 내에 있는 semaphore에게 sema_down()을 호출하여 thread_block()을 기다린다. 기다리는 스레드가 블락되면 해당 스레드의 exit_status를 저장한 뒤에 thread를 unblock()하고 해당 스레드가 완전히 종료될 수 있도로 한다.
3. process_exit
프로세스가 정상적으로 종료되기 위해서는 이 함수가 반드시 호출되어야 한다. 먼저 스레드에 저장한 exec 파일을 close한다. 종료되는 스레드는 먼저 프로세스 구조체 내의 semaphore에게 sema_up()을 호출해 자신의 종료를 알리고 block되며, 이후에 다른 스레드가 process_wait()을 호출하면 다시 unblock되어 완전히 종료를 마친다. 이는 부모 스레드가 wait을 호출해 미리 종료되어있던 자식 스레드의 exit ststus를 불러올 수 있도록 하기 위함이다.
exec()
현재 프로세스를 실행하고 싶은 새로운 실행 가능한 프로세스로 바꾸는 것이다. 그렇기 때문에 보통 fork() 뒤에 exec()을 호출한다.
wait()
부모 프로세스가 자식 프로세스를 기다리는 동작. 일반적으로 자식이 종료된 뒤 SIGCHLD 시그널을 보내면 기다리고있던 부모 프로세스가 받아서 처리하고, 자식 프로세스가 완전히 종료되는 과정이다. 이런 과정이 없으면 좀비 프로세스(자식의 자원은 모두 해체되지만 커널에 의해 프로세스 테이블에는 존재하고 있는 상태)가 되는 것이다.
자식 프로세스의 pid를 인자로 받고, 자식의 exit status를 기다린다. 자식 프로세스가 exit되지 않은 상태라면 종료될 때까지 기다리고, 종료되었을 때의 exit status를 반환한다. 자식 프로세스가 exit() 시스템 콜을 호출하지 않았지만 다른 이유로 커널에 의해 강제적으로 종료되었을 떄는 -1을 반환한다. 이렇게 wait이 실패하는 경우는 아래 두 가지이다.
1. 인자로 받은 pid를 가진 프로세스가 wait()을 호출한 프로세스의 직계 자식이 아닐 때
만약에 A가 B를 fork(), B가 C를 fork()했다면, A는 B가 종료되었을지라도 C를 기다릴 수 없다.
2. 하나의 프로세스는 주어진 자식에 대해 최대 한 번까지만 wait()을 호출할 수 있다.
syscall.c에서 호출하는 sys_wait()에서는 process_wait()을 호출하는 코드가 전부이다. 따라서 이런 과정 구현은 process_wait() 내부에서 해야한다.
플로우를 생각해보자.
1. 현재 실행 중인 스레드(= wait을 호출한 스레드)가 가지고 있는 child_list를 for문으로 훑으며 인자로 받은 pid와 일치하는 자식 프로세스가 있는지 확인한다. 해당하는 자식이 없다면 -1을 리턴한다.
2. sema_down()을 호출한다. => 해당 프로세스는 다른 곳에서 sema_up()이 호출될 때까지 기다려야하고, 그 호출을 하는 객체는 wait() 중인 자식 프로세스여야한다.
3. 기다리고 있는 자식 프로세스가 process_exit()을 통해 exit을 시도했을 때 2번에서 down해준 semaphore를 sema_up() 해준다.
이런식으로 짜봤는데 모든 테스트케이스가 sys_exec() -> thread_exit()을 통과해서 커널 패닉을 내고 있다.
한참 process_create_initd 이런거부터 찾아보다가 process_wait 안에 자식이랑 tid 같은 스레드가 있는지 없는지 찾아주는 함수를 따로 빼지 않고 내부 플로우로 작성했는데, 그걸 따로 함수로 빼서 써주니까 커널 패닉은 안뜨기 시작.
근데 모든 결과가 이제 아무 output을 내지 못한다고 뜸
thread.c -> thread_create() 에