Threads
thread는 프로세스의 context에서 실행되는 logical flow다. 현대의 시스템은 하나의 프로세스에서 동시에 실행되는 여러 스레드를 가진 프로그램을 작성할 수 있게 해 준다. 스레드는 커널에 의해 자동으로 schedule 된다.
각 스레드는 고유한 정수 스레드 ID(TID), 스택, 스택 포인터, 프로그램 카운터, 범용 레지스터, 조건 코드를 포함한 자체 thread context를 가지고 있다. 프로세스에서 실행되는 모든 스레드는 해당 프로세스의 전체 가상 주소 공간을 공유한다.
스레드에 기반한 logical flow는 프로세스 및 I/O multiplexing의 flow에 기반한다.
프로세스와 마찬가지로 스레드는 커널에 의해 자동으로 schedule 되며 정수 ID에 의해 커널에 알려진다.
I/O multiplexing에 기반한 흐름과 마찬가지로 여러 스레드는 단일 프로세스의 맥락에서 실행되므로
code, data, heap, shared libraries, open files을 포함한 프로세스 가상 주소 공간의 전체를 공유한다.
Traditional View of a Process
Process = process context + code, data, and stack
Alternate View of a Process
Process = thread + code, data, and kernel context
A Process With Multiple Threads
- 자신만의 logical control flow를 가지고 있다.
- 자신만의 지역 변수를 위한 stack을 가지고 있다.
-> 다른 스레드로부터 보호되지 않는다. - 자신만의 thread id (TID)를 가지고 있다.
- code, data, kernel context를 공유한다.
Thread Execution Model
Concurrent Threads
multiple 스레드에 대한 excution model은 multiple 프로세스에 대한 모델과 유사하다.
위 그림을 예로 보면 각 프로세스는 main thread라고 하는 단일 스레도 시작한다.
어느 시점에서 main thread가 peer thread를 만들고 이 시점부터 두 스레드가 concurrent 동시에 실행된다.
main thread가 read 또는 sleep 같은 느린 system call을 실행하거나 시스템의 timer interrupt에 의해 중단되기 때문에 context switch를 통해 peer thread로 제어권이 전달된다.
peer thread는 제어권이 main thread로 다시 전달되기 전에 잠시 실행된다.
Logical View of Threads
스레드의 실행은 몇 가지 점에서 프로세스와 다르다.
스레드의 context가 프로세스의 context보다 훨씬 작기 때문에 thread의 context switch가 더 빠르다.
또, 스레드는 프로세스와 달리 계층 구조로 구성되어 있지 않다.
프로세스와 연결된 스레드는 다른 스레드에 의해 작성된 스레드와는 무관하게 peer pool을 형성한다.
main thread는 프로세스에서 항상 실행되는 첫 번째 스레드라는 점에서만 다른 스레드와 구별된다.
이러한 peer pool 개념의 주요 영향은 스레드가 해당 peer를 죽이거나 해당 peer가 종료될 때까지 기다릴 수 있다.
또한 각 peer는 동일한 공유 데이터를 읽고 쓸 수 있다.
Threads vs. Processes
similar
- 자신만의 logical control flow가 있다.
- 다른 것들과 동시에 실행할 수 있다. (가능한 다른 core에서 실행)
- context switch
different
- 스레드는 local stack을 제외한 모든 code와 data를 공유한다
-> 프로세스는 대부분 그렇지 않다. - 스레드는 프로세스보다 대체로 오버헤드가 적다.
-> 프로세스를 생성하거나 reaping 등 제어하는 것은 스레드는 제어하는 것보다 약 2배 정도 오버헤드가 있다.
Posix Threads
Posix threads(Pthreads)는 C 프로그램의 스레드를 다루는 약 60개 함수를 가진 표준 인터페이스다.
- Creating and reaping threads
pthread_create()
pthread_join() - Determining your thread ID
pthread_self() - Terminating threads
pthread_cancel()
pthread_exit()
exit() [terminates all threads] - Synchronizing access to shared variables
pthread_mutex_init
pthread_mutex_[un]lock
The Pthreads “Hello, world!” program.
#include "csapp.h"
void *thread(void *vargp);
int main() {
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
Pthread_join(tid, NULL);
exit(0);
}
void *thread(void *vargp) { /* Thread routine */
printf("Hello, world!\n");
return NULL;
}
main thread는 peer thread를 생성하고 Pthread_join을 호출하여 peer thread가 종료될 때까지 기다린다.
peer thread는 Hello, world!\n을 출력하고 종료된다.
main thread가 peer thread가 종료된 것을 detect 하면, exit을 호출하여 프로세스를 종료한다.
thread의 code와 local data는 thread routine에 encapsulated 되어있다.
void *thread(void *vargp)
위 코드에서 알 수 있듯이, 각 thread routine은 하나의 일반 포인터를 입력으로 사용하고 일반 포인터를 반환한다.
여러 argument를 thread routine에 전달하려면 arguments를 구조체에 넣고 포인터를 구조체로 전달해야 한다.
마찬가지로 thread routine이 여러 arguement를 반환하도록 하려면 구조체에 포인터를 반환할 수 있다.
Threads Functions
Creating Threads
스레드는 pthread_create 함수를 호출하여 생성할 수 있다.
#include <pthread.h>
typedef void *(func)(void *);
int pthread_create(pthread_t *tid, pthread_attr_t *attr,
func *f, void *arg);
Returns: 0 if OK, nonzero on error
pthread_create 함수는 새 스레드를 생성하고 새 스레드의 context와 arg를 인수로 thread routine f를 실행한다.
attr 인수를 사용하여 새로 만든 스레드의 기본 속성을 변경할 수 있다.
이 부분은 여기서 배울 범위를 벗어나기 때문에 attr을 NULL 이외의 값으로 변경할 일은 없다.
pthread_create가 반환되면 tid에 새로 생성된 스레드의 ID가 포함된다.
새 스레드는 pthread_self 함수를 호출하여 자신의 스레드 ID를 결정할 수 있다.
#include <pthread.h>
pthread_t pthread_self(void);
Returns: thread ID of caller
Terminating Threads
스레드는 다음 방법 중 하나로 종료된다.
- 해당 스레드의 top-level 스레드가 반환되면 implicitly 종료된다.
- main 스레드가 pthread_exit을 호출하면 다른 모든 peer 스레드가 종료될 때까지 기다린 다음
반환 값 thread_return으로 main 스레드와 전체 프로세스를 종료한다.
#include <pthread.h>
void pthread_exit(void *thread_return);
Never returns
- 일부 peer 스레드는 프로세스 및 프로세스와 관련된 모든 스레드를 종료하는 linux exit 함수를 호출한다.
- 다른 peer 스레드는 현재 스레드의 ID로 pthread_cancel 함수를 호출하여 현재 스레드를 종료한다.
#include <pthread.h>
int pthread_cancel(pthread_t tid);
Returns: 0 if OK, nonzero on error
Reaping Terminated Threads
스레드는 pthread_join 함수를 호출하여 다른 스레드가 종료될 때까지 기다린다.
#include <pthread.h>
int pthread_join(pthread_t tid, void **thread_return);
Returns: 0 if OK, nonzero on error
pthread_join 함수는 TID가 tid인 thread가 종료될 때까지 block 된다.
thread routine에 반환되는 그리고 generic (void *) pointer를 thread_return이 가리키는 위치에 할당한다.
다음으로 종료된 스레드가 가진 메모리 리소스를 reaping 한다.
Detaching Threads
스레드는 언제든지 join 또는 detach 될 수 있다.
joinable 스레드는 다른 스레드에 의해 reaping 되고 kill 될 수 있다.
스택과 같은 메모리 리소스는 다른 스레드에 의해 reaping 될 때까지 free 되지 않는다.
detached 스레드는 다른 스레드에 의해 reaping 되거나 kill 될 수 없다.
메모리 리소스는 스레드가 종료될 때 시스템에 의해 자동으로 free 된다.
기본적으로 스레드는 join 할 수 있도록 작성된다.
moemory leak을 방지하기 위해 각 joinable 스레드는 다른 스레드에 의해 명시적으로 reaping 되거나 pthread_detach 함수에 대한 호출에 의해 분리되어야 한다.
#include <pthread.h>
int pthread_detach(pthread_t tid);
Returns: 0 if OK, nonzero on error
pthread_detach 함수는 joinable thread tid를 분리한다.
스레드는 pthread_self()를 사용하여 pthread_detach를 호출하여 스스로 분리할 수 있다.
A Concurrent Server Based on Threads
Thread-Based Concurrent Echo Server
#include "csapp.h"
void echo(int connfd);
void *thread(void *vargp);
int main(int argc, char **argv) {
int listenfd, *connfdp;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
pthread_t tid;
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(0);
}
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen = sizeof(struct sockaddr_storage);
connfdp = Malloc(sizeof(int));
*connfdp = Accept(listenfd, (SA *)&clientaddr, &clientlen);
Pthread_create(&tid, NULL, thread, connfdp);
}
}
/* Thread routine */
void *thread(void *vargp) {
int connfd = *((int *)vargp);
Pthread_detach(pthread_self());
Free(vargp);
echo(connfd);
Close(connfd);
return NULL;
}
main 스레드가 connection request를 기다리고 peer 스레드를 생성하여 request를 처리한다.
Issues With Thread-Based Servers
이 예제에는 몇 가지 문제점이 있다.
첫 번째 문제는 pthread_create를 호출할 때 connected descriptor를 peer thread에 전달하는 방법이다.
아래와 같이 포인터로 전달하면 된다.
connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
Pthread_create(&tid, NULL, thread, &connfd);
그다음 peer 스레드가 포인터의 참조를 지역 변수에 할당한다.
void *thread(void *vargp) {
int connfd = *((int *)vargp);
.
.
.
}
하지만, 이 방법은 peer 스레드의 assign 하는 부분과 main 스레드의 accept 부분 사이에 race를 발생시키기 때문에 잘못되었다. 다음 accept 전에 assign이 완료되면 peer 스레드의 로컬 connfd 변수가 올바른 descriptor를 가져온다.
그러나 accept 후에 assign이 완료되면 peer 스레드의 로컬 connfd 변수가 다음 connection의 descriptor를 가져온다.
race를 피하기 위해 아래와 같이 accept에 의해 반환된 각각의 connected descriptor를 동적으로 할당된 메모리 블록에 할당해야 한다.
connfdp = Malloc(sizeof(int));
*connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen);
또 다른 문제는 thread routine에서 memory leak을 피하는 것이다.
예제에서 명시적으로 thread를 reaping하지 않았기 때문에 각 thread가 종료될 때 메모리 리소스를 반환하도록 detach 해야 한다. 또한 main thread에 의해 할당된 메모리를 해제할 때 주의해야 한다.
Pros and Cons of Thread-Based Designs
Pros
- 스레드 간 데이터를 공유하기 쉽다.
ex) logging information, file cache - 프로세스보다 효율적이다.
Cons
- 예상치 못하게 데이터가 공유되어 에러를 발생할 수 있다.
- 어떤 데이터가 공유되고 공유되지 않는지 알기 어렵다.
- debug 하기 어렵다.