Lecture 23: Concurrent Programming
Computer Systems: A Programmer's Perspective (CS:APP)
Concurrent Programming Basics
동시성 프로그래밍에서는 다음과 같은 문제가 발생할 수 있다.
- 경쟁 (Race): 여러 흐름(flow)이 공유 자원에 동시에 접근할 때, 임의의 스케줄링 결정(접근 순서)에 따라 결과가 달라진다.
- 교착 (Deadlock): 여러 흐름이 결코 발생하지 않을 이벤트(서로가 가진 자원)를 기다리며 영원히 차단된다.
- 기아 (Starvation): 부적절한 스케줄링 등으로 인해, 특정 흐름이 필요한 자원을 할당받지 못하고 계속 대기한다.
Iterative Servers
반복 서버(Iterative server)는 한 번에 한 클라이언트의 요청만을 처리한다.
---
config:
flowchart:
htmlLabels: false
---
sequenceDiagram
participant C1 as Client 1
participant S as Server
participant C2 as Client 2
C1-->>S: connect
Note over S: accept (client 1)
C2-->>S: connect
C1-->>S: write
Note over C1: call read
Note over S: read (client 1)
C2-->>S: write
Note over C2: call read
S-->>C1: write
Note over C1: ret read
C1-->>S: close
Note over S: accept (client 2)
Note over S: read (client 2)
S-->>C2: write
Note over C2: ret read
커널은 연결 요청과 작성(write)된 데이터를 대기열에 넣을 수 있다. 따라서 클라이언트에서
connect()
와write()
호출은 즉시 반환되지만,read()
호출 시 데이터를 읽을 때까지 반환되지 않으므로 호출자가 차단된다.
Approaches for Writing Concurrent Servers
동시 서버(Concurrent server)는 동시 흐름(Concurrent flow)을 통해 각 클라이언트와 상호 작용한다. 동시 흐름을 생성하는 방법에는 3가지가 있다.
프로세스 기반 (Process-based)
- 커널이 모든 스케줄링을 처리한다.
- 각 흐름(프로세스)은 자체적인 주소 공간을 가지므로, 서로 독립적이다.
이벤트 기반 (Event-based)
- 입출력 다중화(I/O multiplexing)를 통해 흐름을 interleave한다.
- 모든 흐름이 동일한 주소 공간을 공유한다.
스레드 기반 (Thread-based)
- 커널이 흐름을 interleave한다.
- 각 흐름(스레드)은 동일한 주소 공간을 공유한다.
Process-Based Servers
Process-Based Concurrent Echo Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void sigchld_handler(int sig) {
while (waitpid(-1, 0, WNOHANG) > 0)
;
}
int main(int argc, char *argv[]) {
int listenfd, connfd;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
if (argc != 2) {
fprintf(stderr, "Usage: %s <port>\n", argv[0]);
return 2;
}
Signal(SIGCHLD, sigchld_handler);
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen = sizeof(struct sockaddr_storage);
connfd = Accept(listenfd, (struct sockaddr *)&clientaddr, &clientlen);
if (Fork() == 0) {
Close(listenfd);
echo(connfd);
Close(connfd);
exit(0);
}
Close(connfd);
}
}
- 서버는 연결 요청을 수락하여
accept()
호출에서 반환된 후, 자식 프로세스를 생성하여 클라이언트와 상호 작용한다.
Issues with Process-Based Servers
SIGCHLD
핸들러에서 종료된 모든 자식 프로세스를 회수하여 메모리 누수를 방지해야 한다.부모 프로세스는
connfd
를 사용하지 않으므로 닫아 주어야 한다. 커널은 열려 있는 각 소켓에 대해refcnt
를 유지하는데,refcnt
가 0이 될 때까지 연결이 닫히지 않는다.
Pros and Cons of Process-Based Servers
장점
- 각 프로세스는 독립된 주소 공간을 가진다.
- 동시 서버를 구현하는 가장 간단한 방법이다.
단점
- 프로세스 제어 시 발생하는 오버헤드가 크다.
- 프로세스 간 데이터 공유가 까다롭다.
Event-Based Servers
이벤트 기반 서버는 listenfd
와 connfd
의 배열(활성화된 연결의 집합)을 가지고 있으며, select()
나 epoll()
과 같은 시스템 콜을 사용하여 어느 디스크립터에 입력이 대기 중인지 알아내어 작업을 수행한다. (I/O multiplexing)
디스크립터에 데이터가 도착하면 디스크립터의 상태가 변경되는데, 이를 이벤트라 한다.
Pros and Cons of Event-Based Servers
장점
- 단일 프로세스 및 스레드로 동작하기 때문에, 디버거를 통해 단계별 실행이 가능하여 디버깅이 수월하다.
- 프로세스 및 스레드 제어가 필요 없기 때문에, 오버헤드가 매우 적다.
Node.js, nginx, Tornado와 같은 고성능 웹 서버는 모두 이벤트 기반 모델을 사용한다.
단점
- 프로세스나 스레드 기반 서버에 비해 구현이 훨씬 복잡하다.
- 하나의 작업이 오래 걸리면 다른 작업들이 차단되어 지연될 수 있다.
- 멀티 코어를 활용할 수 없다.
Thread-Based Servers
Traditional View of a Process
---
config:
flowchart:
htmlLabels: false
---
graph LR
brk["brk pointer"]
code["Code"]
condition-code["Condition codes"]
data["Data"]
data-register["Data registers"]
descriptor-table["Descriptor table"]
pc["Program counter (PC)"]
sp["Stack pointer (SP)"]
stack["Stack"]
vm-structure["VM structures"]
subgraph process["Process"]
subgraph process-context["Process context"]
direction TB
subgraph program-context["Program context"]
data-register
condition-code
sp
pc
end
subgraph kernel-context["Kernel context"]
vm-structure
descriptor-table
brk
end
end
subgraph vm["Private address space"]
stack
data
code
end
end
Alternate View of a Process
---
config:
flowchart:
htmlLabels: false
---
graph LR
brk["brk pointer"]
code["Code"]
condition-code["Condition codes"]
data["Data"]
data-register["Data registers"]
descriptor-table["Descriptor table"]
pc["Program counter (PC)"]
sp["Stack pointer (SP)"]
stack["Stack"]
vm-structure["VM structures"]
subgraph process["Process"]
subgraph thread["Thread (main thread)"]
direction TB
subgraph thread-context["Thread context"]
data-register
condition-code
sp
pc
end
stack
end
data
code
subgraph kernel-context["Kernel context"]
vm-structure
descriptor-table
brk
end
end
A Process with Multiple Threads
---
config:
flowchart:
htmlLabels: false
---
graph LR
brk["brk pointer"]
code["Code"]
condition-code1["Condition codes"]
condition-code2["Condition codes"]
data["Data"]
data-register1["Data registers"]
data-register2["Data registers"]
descriptor-table["Descriptor table"]
pc1["PC 1"]
pc2["PC 2"]
sp1["SP 1"]
sp2["SP 2"]
stack1["Stack 1"]
stack2["Stack 2"]
vm-structure["VM structures"]
subgraph process["Process"]
subgraph thread1["Thread 1 (main thread)"]
direction TB
subgraph thread1-context["Thread 1 context"]
data-register1
condition-code1
sp1
pc1
end
stack1
end
subgraph thread2["Thread 2 (peer thread)"]
direction TB
subgraph thread2-context["Thread 2 context"]
data-register2
condition-code2
sp2
pc2
end
stack2
end
data
code
subgraph kernel-context["Kernel context"]
vm-structure
descriptor-table
brk
end
end
- 커널은 각 스레드를 별도의 제어 흐름으로 처리하고, 프로세스와 유사한 방식으로 스케줄링한다.
- 프로세스에 비해, 스레드 생성/회수 및 콘텍스트 전환 시 발생하는 오버헤드가 적다.
Logical View of Threads
---
config:
flowchart:
htmlLabels: false
---
graph TD
bar(("bar"))
foo(("foo"))
p0(("P0"))
p1(("P1"))
sh1(("sh"))
sh2(("sh"))
sh3(("sh"))
shared["Shared code, data<br>and kernel context"]
t1(("T1"))
t2(("T2"))
t3(("T3"))
t4(("T4"))
t5(("T5"))
subgraph process["Process hierarchy"]
p0 --> p1 --> sh1 & sh2 & sh3
sh2 --> foo --> bar
end
subgraph thread["Threads associated with process"]
shared <-.-> t1 & t2 & t3 & t4 & t5
end
Pthreads Interface
1
2
3
4
5
6
7
8
9
10
11
12
/* Thread routine */
void *thread(void *vargp) {
printf("Hello, world!\n");
return NULL;
}
int main() {
pthread_t tid;
Pthread_create(&tid, NULL, thread, NULL);
Pthread_join(tid, NULL);
return 0;
}
Pthread_create()
를 통해 생성된 스레드는 스레드 루틴을 실행한다.- 스레드 루틴은
void *
를 선택적 인자로 받는다. 스레드에 무언가를 전달하고 싶다면, 모든 데이터를 하나의 객체로 묶어서 그 주소를 전달해야 한다.
- 스레드 루틴은
- 메인 스레드는
Pthread_join()
을 호출하여 peer 스레드가 종료되기를 기다린다.
Thread-Based Concurrent Echo Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* Thread routine */
void *thread(void *vargp) {
int connfd = *((int *)vargp);
Pthread_detach(pthread_self());
Free(vargp);
echo(connfd);
Close(connfd);
return NULL;
}
int main(int argc, char *argv[]) {
int listenfd, *connfdp;
socklen_t clientlen;
struct sockaddr_storage clientaddr;
pthread_t tid;
char client_hostname[MAXLINE], client_port[MAXLINE];
if (argc != 2) {
fprintf(stderr, "Usage: %s <port>\n", argv[0]);
return 2;
}
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen = sizeof(struct sockaddr_storage);
connfdp = Malloc(sizeof(int));
*connfdp = Accept(listenfd, (struct sockaddr *)&clientaddr, &clientlen);
Pthread_create(&tid, NULL, thread, connfdp);
Getnameinfo((struct sockaddr *)&clientaddr, clientlen, client_hostname,
MAXLINE, client_port, MAXLINE, 0);
printf("Connected to (%s, %s) via thread %d\n",
client_hostname, client_port, (int) tid);
}
}
각 스레드는 자체적인 스택을 가지므로, 서로 독립적으로 실행될 수 있다.
Issues with Thread-Based Servers
잠재적인 메모리 누수를 방지하기 위해, 스레드를 detached 모드로 실행해야 한다. (Pthread_detach()
)
- Joinable (기본값): 다른 스레드에 의해 종료될 수 있으며,
Pthread_join()
을 통해 회수되어야 한다. - Detached: 다른 스레드에 의해 종료될 수 없으며, 종료 시 커널에 의해 자동으로 회수된다.
Pros and Cons of Thread-Based Servers
장점
- 스레드 간 데이터 공유가 매우 쉽다.
- 스레드는 프로세서보다 효율적이다.
단점
- 의도하지 않은 자원 공유가 발생하지 않도록 주의해야 한다.
- 경쟁 상태가 발생하면 디버깅하기가 까다롭다.