프로세스와 쓰레드의 생성과 수행
프로세스
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int g = 2;
int main() {
pid_t pid;
int i = 3;
printf("PID(%d): parent g=%d, l=%d\n", getpid(), g, i);
if ((pid = fork()) < 0) {
perror("fork error");
exit(1);
} else if (pid == 0) {
g++;
i++;
} else {
wait();
}
printf("PID(%d): parent g=%d, l=%d\n", getpid(), g, i);
return 0;
}
-
전역변수
g
와l
을 가지고 있는 프로그램을fork()
함수를 통해서 새로운 프로세스를 생성하였다. -
자식 프로세스에 전역 변수와 지역 변수를 각각 1씩 증가시켰기 때문에 값이 3, 4로 증가되어있는 모습을 확인할 수 있다. 반면에 부모 프로세스에서
g
와l
을 다시 출력하면 각각 2, 3으로 되어있는 것을 확인할 수 있다. -
이를 통해서 프로세스가 생성되면 주소 공간을 포함하여 이 프로세스를 위한 모든 자원들이 새롭게 할당 됨을 확인할 수 있다. 따라서 자식 프로세스의 연산 결과는 자식 프로세스 주소 공간의 변수에만 영향을 줄 뿐 부모 프로세스 주소 공간의 변수에는 영향이 없으며, 결국 지역 변수, 전역 변수 등의 값이 다르게 출력된 것이다.
쓰레드
- 만약 쓰레드에서는 수행 결과가 어떠한지 차이를 살펴보자. 리눅스에서는 쓰레드 생성을 위해서
clone()
이라는 시스템 호출을 제공한다.
#define _GNU_SOURCE
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sched.h>
int g = 2;
int sub_func(void *arg)
{
g++;
printf("PID(%d): child=%d\n", getpid(), g);
sleep(2);
return 0;
}
int main() {
int pid;
int child_stack[4096];
int l = 3;
printf("PID(%d) : parent g=%d, l=%d\n", getpid(), g, l);
clone(sub_func, (void *)(child_stack + 4095), CLONE_VM | CLONE_THREAD | CLONE_SIGHAND, NULL);
sleep(1);
printf("PID(%d) : parent g=%d, l=%d\n", getpid(), g, l);
return 0;
}
-
결과를 확인해보면, 일단 프로세스가 같은 것을 확인할 수 있다. 그리고, 전역 변수 값이 변경된 것을 확인할 수 있다.
-
즉, 기존에 수행되던 쓰레드는 자신이 생성한 쓰레드가 변수를 수정하면 그 수정된 결과를 그대로 볼 수 있다.
-
결론은 새로운 프로세스를 생성하면, 생성된 프로세스 (자식 프로세스)와 생성한 프로세스 (부모 프로세스) 는 서로 다른 주소의 공간을 갖는 반면에 새로운 쓰레드를 생성하면 같은 주소 공간을 공유한다.
-
한 프로세스에서 여러 쓰레드가 동작하는 모델을 다중 쓰레드 시스템이라고 하며 쓰레드 생성은 모든 자원을 새롭게 생성해 주어야 했던 프로세스에 비해서 생성에 드는 비용이 비교적 적다.
-
그리고 자식 쓰레드에서 결함이 발생하면 그것은 부모 쓰레드로 전파된다. 반면에 자식 프로세스에서 발생한 결함은 부모 프로세스에게 전파되지 않는다.
-
결국 쓰레드 모델은 지원 공유에 적합하며, 프로세스 모델은 결함 고립에 적합한 모델이다.
프로세스의 수행
- 리눅스는 태스크의 수행을 위해
excel()
이라는 시스템 호출을 제공한다.
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
pid_t pid;
int exit_status;
if ((pid = fork()) < 0) {
perror("fork error");
exit(1);
} else if (pid == 0) {
printf("before exec\n");
execl("./fork", "fork", (char *)0);
printf("after exec\n");
} else {
pid = wait(&exit_status);
}
printf("parent\n");
return 0;
}
-
새로운 프로세스를 생성한 후에 생성된 프로세스에서
execl()
를 호출하여fork
라는 바이너리 파일을 수행한다. -
“after exec” 라는 문자열은 출력되지 않았는데 이는
execl()
이 성공적으로 수행되면 프로세스의 수행 이미지 (텍스트, 데이터, 스택 등)가 기존의 것에서 새로운 것으로 바뀌며 이 때문에 “after exec"를 출력하는printf()
는 수행되지 않는다. -
마지막으로 프로세스 생성 및 수행과 관련되어서 하나만 더 언급하지만
fork()
와vfork()
의 차이이다. 둘다 프로세스를 생성하지만,fork()
는 부모 프로세스의 공간을 복사하여 자식 프로세스의 공간을 따로 만들지만vfork()
의 경우 일단은 같은 주소 공간을 가리킨다. -
이때
execl()
이 자식 프로세스에서 수행되었다고 가정을 하면, 기존에 사용하던 프로세스의 주소 공간을 모두 없애고, 요청된 바이너리를 기반으로 새로운 주소 공간을 생성하는데, 이러면 자식 프로세스의 공간을 따로 만들어주었던 것이 불필요한 작업이 된다. 따라서 이러한 비효율을 제거하는 것이 바로vfork()
이다. -
최근 리눅스는
COW
기법을 도입하여fork()
할 떄 야기되는 주소 공간 복사 비용을 많이 줄였다.
리눅스의 태스크 모델
-
위에서는 사용자 입장에서 프로세스와 쓰레드를 어떻게 생성하는지 살펴보았지만, 리눅스 커널에서는 이러한 객체들을 어떻게 구현하는지 살펴보자.
-
프로세스는 자신이 사용하는 자원과 그 자원에서 수행되는 수행 흐름으로 구성된다. 그리고 리눅스에서는 이를 관리하기 위해서 각 프로세스마다
task_struct
라는 자료 구조를 생성한다. -
그럼 자원을 의미하는 사각형이 하나 그려지고 이 자원에서 수행되는 수행의 흐름을 의미하는 실선이 하나 생성된다. 그리고 리눅스에서는
task_struct
자료 구조를 생성한다. -
스레드를 만들었을 때도 기존에 존재하는 프로세스에 실선이 하나 생성되고 리눅스 커널에서는
task_struct
가 하나 생성된다. 결국 리눅스에서는 프로세스가 생성되든 쓰레드가 생성되든task_struct
라는 동일한 자료 구조를 생성하여 관리한다. -
결국 리눅스 커널은 프로세스 또는 쓰레드 중에서 어떤 것이 요청될 지라도 모두,
task_struct
자료 구조로 동일하게 관리한다. 단지task_struct
자료 구조 중에서 수행 이미지를 공유하는가, 같은 쓰레드 그룹에 속해 있는가 등의 여부에 따라 프로세스, 또는 쓰레드로 사용자에게 해석되는 차이가 있을 뿐이다. -
결국에는 사용자의 프로세스 혹은 쓰레드 생성 요청은 라이브러리를 거쳐서 시스템 호출을 통해 리눅스 커널에 전달된다. 결국에
fork(), vfork(), clone(), pthread_create()
함수들은 모두do_work()
를 호출한다. -
fork()
는 비교적 부모 태스크와 덜 공유하는 태스크이고,clone()
으로 생성되는 태스크는 비교적 부모 태스크와 많이 공유하는 태스크이다. 즉,do_fork()
를 호출할 때 이 함수의 인자로 부모 태스크와 얼마나 공유할지는 정해줌으로써 fork(), clone() 모두를 지원할 수 있는 것이다. -
do_fork()
는 우선 새로 생성되는 태스크를 위해서 일종의 이름표를 하나 준비한다. 이 이름표에 새로이 생성된 태스크의 이름과 태어난 시간, 부모님 이름, 소지품 등 매우 자세한 정보를 기록해 둔다. 그래야 나중에 새롭게 생성된 태스크를 쉽게 찾아내고 그 태스크의 정보를 알 수 있을 것이다. 이름표라는 것은task_struct
구조체이다. -
커널 내부에서는 쓰레드이건 프로세스이건 모두 태스크 구조체로 표현이 되는데 그렇다면 사용자들은 어떤 조건이 만족될 때 태스크를 프로세스라고 부르며 반대로 어던 조건이 만족될 떄 태스크를 쓰레드라고 부를까?
-
시스템에 존재하는 모든 태스크는 유일하게 구분이 가능해야 한다. 태스크 별로 유일한 이 값은
task_struct
구조체 내의pid
필드에 담겨있다. 그런데POSIX
표준에 의하면 ‘한 프로세스 내의 쓰래드는 동일한 PID를 공유해야 한다’ 라고 명시되어 있다. 리눅스에서는 이를 위해tgid
(Thread Group ID) 라는 개념을 도입했다. -
태스크가 생성되면 이 태스크를 위한 유일한 번호를
pid
로 할당해 준다. 그런 다음에 만약 사용자가 프로세스를 원하는 경우라면 생성된 태스크의tgid
값을 새로 할당된pid
값과 동일하게 넣어준다. -
따라서
tgid
값도 유일한 번호를 갖게 된다. 사용자가 쓰레드를 원하는 경우라면 부모 쓰레드의tgid
값과 동일한 값으로 생성된 태스크의tgid
를 설정한다. 결국 부모 태스크의 자식 태스크는 동일한tgid
를 갖게 되며 동일한 프로세스에 속해있는 것으로 해석된다.
예제
- 다음은 위의 내용을 실험해본 내용이다.
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <linux/unistd.h>
int main() {
int pid;
printf("before fork\n");
if ((pid = fork()) < 0) {
printf("fork error\n");
exit(-2);
} else if (pid == 0) {
printf("TGID(%d), PID(%ld): child \n", getpid(), syscall(__NR_gettid));
} else {
printf("TGID(%d), PID(%ld): parent \n", getpid(), syscall(__NR_gettid));
sleep(2);
}
printf("after fork\n");
return 0;
}
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <linux/unistd.h>
int main() {
int pid;
printf("before fork\n");
if ((pid = vfork()) < 0) {
printf("fork error\n");
exit(-2);
} else if (pid == 0) {
printf("TGID(%d), PID(%ld): child \n", getpid(), syscall(__NR_gettid));
_exit(0);
} else {
printf("TGID(%d), PID(%ld): parent \n", getpid(), syscall(__NR_gettid));
sleep(2);
}
printf("after fork\n");
return 0;
}
-
fork(), vfork()
에서는 각 태스크의pid
와tgid
가 부모 태스크와 자식 태스크 간에 서로 다른 것을 확인할 수 있다. -
즉 사용자 입장에서는 서로 다른 프로세스가 만들어진 것이다.
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <linux/unistd.h>
#include <pthread.h>
void *t_function(void *data) {
int id;
int i = 0;
pthread_t t_id;
id = *((int *) data);
printf("TGID (%d), PID(%d), pthread_self(%d) : child \n", getpid(), syscall(__NR_gettid), pthread_self());
sleep(2);
}
int main() {
int pid, status;
int a = 1;
int b = 2;
pthread_t p_thread[2];
printf("before pthread_create \n");
if ((pid = pthread_create(&p_thread[0], NULL, t_function, (void *)&a)) < 0) {
perror("thread create error: ");
exit(1);
}
if ((pid = pthread_create(&p_thread[1], NULL, t_function, (void *)&b)) < 0) {
perror("thread create error: ");
exit(2);
}
pthread_join(p_thread[0], (void **)&status);
printf("pthread_join(%d)\n", status);
pthread_join(p_thread[1], (void **)&status);
printf("pthread_join(%d)\n", status);
printf("TGID(%d), PID(%d) : parent\n", getpid(), syscall(__NR_gettid));
return 0;
}
-
반면에
pthread_create()
에서는 각 태스크의pid
는 서로 다르지만tgid
는 서로 동일함을 알 수 있다. -
같은 프로세스 내부에 서로다른 쓰레드 2개가 생긴 것이다.
#define _GNU_SOURCE
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <linux/unistd.h>
#include <sched.h>
int sub_func(void *arg)
{
printf("TGID(%d), PID(%d): child \n", getpid(), syscall(__NR_gettid));
sleep(2);
return 0;
}
int main() {
int pid;
int child_a_stack[4096], child_b_stack[4096];
printf("before clone \n\n");
printf("TGID(%d), PID(%d) : parent \n", getpid(), syscall(__NR_gettid));
clone(sub_func, (void *)(child_a_stack + 4095), CLONE_CHILD_CLEARTID | CLONE_CHILD_SETTID, NULL);
clone(sub_func, (void *)(child_b_stack + 4095), CLONE_VM | CLONE_THREAD | CLONE_SIGHAND, NULL);
sleep(1);
printf("after clone \n\n");
return 0;
}
-
해당 코드는
clone()
을 이용해서, 프로세스와 쓰레드를 만드는 예를 보여준다. -
리눅스에서는
pid
,tgid
, 자원 공유 여부의 선택이 자유로워, 어떤 형태의 태스크라도 만들 수 있다.
참고 문헌
>> Home