在 Unix/Linux 系統程式設計中,多進程 是實現並發網路服務的經典模型。相較於多線程,進程擁有獨立地址空間,天然隔離故障(單進程崩潰不影響其他連接),且無需複雜的鎖同步機制。雖然 fork() 複製頁表有開銷,但現代作業系統採用 Copy-on-Write (COW) 機制,實際開銷極低。本文通過一個完整的「單向回應伺服器」範例,系統講解 Socket 程式設計標準流程、錯誤處理最佳實踐、進程管理與訊號安全 I/O。
accept()
/*********************************************************************
* Unix 多進程 TCP 回應伺服器
* 編譯: gcc -o server server.c
* 執行: ./server <IP> <PORT> (例: ./server 0.0.0.0 8888)
* 參考: Stevens "UNIX Network Programming" / "Advanced Programming in the UNIX Environment"
*********************************************************************/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>
#define LISTENQ 5
#define BUFFER_SIZE 1024
/* 子進程處理函數:回應歡迎訊息並回傳收到的資料 */
int child_process(int connfd) {
ssize_t written;
char *welcome = "Hello, welcome to my echo server!\n";
/* 安全寫入:處理 EINTR 訊號中斷 */
again_write:
if ((written = write(connfd, welcome, strlen(welcome))) < 0) {
if (errno == EINTR) goto again_write; // 被訊號中斷,重試
perror("write failed");
return -1;
}
printf("Sent welcome message (%zd bytes)\n", written);
char buffer[BUFFER_SIZE];
ssize_t n;
/* 迴圈讀取客戶端資料並回傳 */
for (;;) {
again_read:
if ((n = read(connfd, buffer, BUFFER_SIZE)) < 0) {
if (errno == EINTR) goto again_read;
perror("read failed");
break;
}
if (n == 0) { // 客戶端關閉連接 (EOF)
printf("Client closed connection\n");
break;
}
buffer[n] = '\0'; // 確保字串結束
/* 取得對端地址資訊用於日誌 */
struct sockaddr_in peer_addr;
socklen_t peer_len = sizeof(peer_addr);
if (getpeername(connfd, (struct sockaddr*)&peer_addr, &peer_len) == 0) {
printf("Received from %s:%d: %s",
inet_ntoa(peer_addr.sin_addr), ntohs(peer_addr.sin_port), buffer);
}
/* 回傳資料 (Echo) */
ssize_t total_written = 0;
while (total_written < n) {
written = write(connfd, buffer + total_written, n - total_written);
if (written <= 0) {
if (errno == EINTR) continue;
perror("write echo failed");
goto cleanup;
}
total_written += written;
}
}
cleanup:
close(connfd);
return 0;
}
/* 訊號處理:回收殭屍進程 */
void sig_chld(int signo) {
pid_t pid;
int stat;
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) {
printf("Child %d terminated\n", pid);
}
return;
}
int main(int argc, char **argv) {
if (argc < 3) {
fprintf(stderr, "Usage: %s <IP> <PORT>\n", argv[0]);
exit(1);
}
/* 註冊 SIGCHLD 處理器,避免殭屍進程 */
signal(SIGCHLD, sig_chld);
/* Step 1: socket() */
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
if (listenfd < 0) { perror("socket failed"); exit(1); }
/* Step 2: bind() */
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = PF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]); // 支援 "0.0.0.0" 綁定所有介面
if (bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0) {
perror("bind failed"); close(listenfd); exit(1);
}
/* Step 3: listen() */
if (listen(listenfd, LISTENQ) < 0) {
perror("listen failed"); close(listenfd); exit(1);
}
printf("Server listening on %s:%s\n", argv[1], argv[2]);
/* Step 4: 主迴圈 accept() + fork() */
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int connfd;
int connect_count = 0;
for (;;) {
connfd = accept(listenfd, (struct sockaddr*)&cliaddr, &clilen);
if (connfd < 0) {
if (errno == EINTR) continue; // 被訊號中斷 (如 SIGCHLD),重試 accept
perror("accept failed");
continue; // 不退出主進程,嘗試繼續服務
}
printf("New connection %d from %s:%d\n",
++connect_count, inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port));
/* Step 5: fork() 子進程處理連接 */
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
close(connfd);
} else if (pid == 0) { // 子進程
close(listenfd); // 子進程不需監聽 fd
child_process(connfd);
exit(0); // 子進程結束
} else { // 父進程
close(connfd); // 父進程不需已連接 fd
// 父進程繼續下一輪 accept()
}
}
close(listenfd);
return 0;
}
EINTR 處理當進程處理訊號(如 SIGCHLD)時,阻塞的系統呼叫(read/write/accept)會被中斷,回傳 -1 並設定 errno = EINTR。必須重試,否則會導致資料遺失或連接異常斷開。範例採用 goto again 模式,清晰可靠。
SIGCHLD + waitpid(WNOHANG)子進程結束後若父進程未 wait(),會變成殭屍進程佔用 PID。解決方案:
SIGCHLD 處理器waitpid(-1, &stat, WNOHANG) 非阻塞回收所有已結束子進程printf 實際不安全,生產環境應用 write(STDOUT_FILENO, ...) 或單純回收不列印| 進程 | 關閉 | 保留 | 原因 |
|---|---|---|---|
| 父進程 | connfd (已連接 fd) | listenfd | 父進程只負責 accept,不處理 I/O;若不關閉會洩漏 fd,導致連接無法正常關閉 (FIN 不會發送) |
| 子進程 | listenfd | connfd | 子進程只負責當前連接 I/O;保留 listenfd 會導致多進程競爭 accept (thundering herd),且阻止埠釋放 |
| 問題 | 解決方案 |
|---|---|
fork() 大量進程開銷 | 預先派生進程池 (Pre-forking) / 線程池 / epoll/kqueue/IOCP 事件驅動 |
| 驚群效應 | 單進程 accept + 分發 / SO_REUSEPORT (Linux 3.9+) |
| 阻塞 I/O 限制併發 | 非阻塞 I/O + I/O 多路複用 / 非同步 I/O (io_uring) |
| 訊號處理器不安全 | signalfd + epoll 統一事件迴圈 / pthread_sigmask 專用訊號處理線程 |
| 缺乏優雅關閉 | 捕捉 SIGTERM/SIGINT,關閉 listenfd、通知子進程退出、等待回收 |