文章目录
- [Linux 多线程 TCP 服务端与客户端:基于 pthread 的完整实现与多进程对比](#Linux 多线程 TCP 服务端与客户端:基于 pthread 的完整实现与多进程对比)
- [二、Linux 多线程 TCP 服务端](#二、Linux 多线程 TCP 服务端)
- [三、Linux 多线程 TCP 客户端](#三、Linux 多线程 TCP 客户端)
-
- [3.1 完整客户端代码](#3.1 完整客户端代码)
- 四、编译和运行
-
- [4.1 编译服务端](#4.1 编译服务端)
- [4.2 编译客户端](#4.2 编译客户端)
- [4.3 启动服务端](#4.3 启动服务端)
- [4.4 启动多线程客户端](#4.4 启动多线程客户端)
- [五、多线程服务端中的 fd 到底如何共享](#五、多线程服务端中的 fd 到底如何共享)
-
- [5.1 多进程中的文件描述符](#5.1 多进程中的文件描述符)
- [5.2 多线程中的文件描述符](#5.2 多线程中的文件描述符)
- 六、多进程与多线程的内存模型差异
-
- [6.1 多进程模型](#6.1 多进程模型)
- [6.2 多线程模型](#6.2 多线程模型)
- 七、参数传递方式的差异
-
- [7.1 多进程中的循环变量](#7.1 多进程中的循环变量)
- [7.2 多线程中的错误写法](#7.2 多线程中的错误写法)
- [7.3 正确方式一:为每个线程分配独立参数](#7.3 正确方式一:为每个线程分配独立参数)
- [7.4 正确方式二:使用地址稳定的参数数组](#7.4 正确方式二:使用地址稳定的参数数组)
- 八、线程回收与进程回收
- 九、为什么日志输出需要互斥锁
- [十、为什么不能直接假设一次 write 对应一次 read](#十、为什么不能直接假设一次 write 对应一次 read)
- [十一、为什么使用 MSG_NOSIGNAL](#十一、为什么使用 MSG_NOSIGNAL)
- 十二、原多线程草稿中的具体问题
-
- 12.1 include 缺少 `#`
- [12.2 `sockarr` 拼写错误](#12.2
sockarr拼写错误) - [12.3 换行符写错](#12.3 换行符写错)
- [12.4 字符串结束符写错](#12.4 字符串结束符写错)
- [12.5 创建失败后仍然 join](#12.5 创建失败后仍然 join)
- [12.6 pthread 错误码处理错误](#12.6 pthread 错误码处理错误)
- [12.7 `pthread_self()` 不等于可直接打印的 Linux TID](#12.7
pthread_self()不等于可直接打印的 Linux TID) - [12.8 `rand()` 不适合作为线程共享状态示例](#12.8
rand()不适合作为线程共享状态示例)
- 十三、多进程与多线程核心差异表
- [十四、Linux 内核视角:进程和线程并非完全不同](#十四、Linux 内核视角:进程和线程并非完全不同)
- 十五、多线程不一定在所有场景下都优于多进程
- 十六、一连接一线程模型的扩展性边界
- 十七、最终总结
Linux 多线程 TCP 服务端与客户端:基于 pthread 的完整实现与多进程对比
一、从多进程模型改为多线程模型
在多进程 TCP 服务端中,主进程负责:
-
创建监听套接字;
-
调用
accept()接收客户端连接; -
每建立一个连接,就调用
fork()创建子进程; -
子进程负责与对应客户端通信;
-
父进程通过
waitpid()回收结束的子进程。
多线程服务端的整体流程非常相似,只是把 fork() 替换为 pthread_create():
text
多进程服务端:
主进程
│
├── accept() ── fork() ── 子进程1处理客户端1
│
├── accept() ── fork() ── 子进程2处理客户端2
│
└── accept() ── fork() ── 子进程3处理客户端3
多线程服务端:
主线程
│
├── accept() ── pthread_create() ── 工作线程1处理客户端1
│
├── accept() ── pthread_create() ── 工作线程2处理客户端2
│
└── accept() ── pthread_create() ── 工作线程3处理客户端3
虽然代码结构相似,但两者的资源模型差异很大。
多进程中的父子进程拥有各自独立的地址空间和文件描述符表副本;多线程中的所有线程共享进程地址空间、全局变量、堆区以及文件描述符表。
二、Linux 多线程 TCP 服务端
2.1 服务端职责划分
多线程服务端包含两类线程。
主线程
主线程负责:
-
创建监听套接字
lfd; -
绑定服务器地址;
-
监听连接;
-
循环调用
accept(); -
为每个客户端创建工作线程;
-
将客户端通信套接字
cfd交给工作线程。
工作线程
每个工作线程负责:
-
接收一个客户端的消息;
-
执行业务处理;
-
向客户端返回响应;
-
关闭自己的
cfd; -
结束线程。
执行流程如下:
text
主线程 工作线程
socket()
│
bind()
│
listen()
│
accept()
│
获得 cfd
│
创建 ClientInfo
│
pthread_create() ────────────────────────────────► 接管 ClientInfo
│ │
│ recv()
│ │
继续 accept() 业务处理
│ │
│ send()
│ │
│ close(cfd)
│ │
│ return nullptr
2.2 完整服务端代码
cpp
// server_thread.cpp
#include <arpa/inet.h>
#include <cerrno>
#include <chrono>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <memory>
#include <netinet/in.h>
#include <pthread.h>
#include <sstream>
#include <string>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <unistd.h>
using namespace std;
// 每个工作线程对应一个 ClientInfo
struct ClientInfo {
int cfd; // 已连接套接字
sockaddr_in client_addr; // 客户端地址
};
// 保护日志输出,避免多个线程的日志互相穿插
pthread_mutex_t g_log_mutex = PTHREAD_MUTEX_INITIALIZER;
string get_timestamp();
long get_kernel_tid();
void log_message(const string& message);
ssize_t send_all(int fd, const void* data, size_t len);
ssize_t recv_line(int fd, string& out, size_t max_len = 4096);
void* do_connect(void* arg);
int main() {
// ---------------------------------------------------------
// 1. 创建监听套接字
// ---------------------------------------------------------
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1) {
cerr << "socket() failed: " << strerror(errno) << '\n';
return 1;
}
// ---------------------------------------------------------
// 2. 设置地址复用
// ---------------------------------------------------------
int opt = 1;
if (setsockopt(
lfd,
SOL_SOCKET,
SO_REUSEADDR,
&opt,
sizeof(opt)) == -1) {
cerr << "setsockopt() failed: "
<< strerror(errno) << '\n';
close(lfd);
return 1;
}
// ---------------------------------------------------------
// 3. 设置服务器地址
// ---------------------------------------------------------
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(8888);
// ---------------------------------------------------------
// 4. 绑定地址
// ---------------------------------------------------------
if (bind(
lfd,
reinterpret_cast<sockaddr*>(&server_addr),
sizeof(server_addr)) == -1) {
cerr << "bind() failed: "
<< strerror(errno) << '\n';
close(lfd);
return 1;
}
// ---------------------------------------------------------
// 5. 开始监听
// ---------------------------------------------------------
if (listen(lfd, 128) == -1) {
cerr << "listen() failed: "
<< strerror(errno) << '\n';
close(lfd);
return 1;
}
cout << "Server listening on 0.0.0.0:8888"
<< ", PID=" << getpid() << '\n';
// ---------------------------------------------------------
// 6. 创建线程属性
// ---------------------------------------------------------
pthread_attr_t attr;
int ret = pthread_attr_init(&attr);
if (ret != 0) {
// pthread 系列函数通常直接返回错误码,
// 不一定通过 errno 返回错误。
cerr << "pthread_attr_init() failed: "
<< strerror(ret) << '\n';
close(lfd);
return 1;
}
// 设置线程为分离状态。
//
// 分离线程结束后,其线程控制块、线程栈等资源
// 会由系统自动回收,不需要 pthread_join()。
ret = pthread_attr_setdetachstate(
&attr,
PTHREAD_CREATE_DETACHED
);
if (ret != 0) {
cerr << "pthread_attr_setdetachstate() failed: "
<< strerror(ret) << '\n';
pthread_attr_destroy(&attr);
close(lfd);
return 1;
}
// ---------------------------------------------------------
// 7. 主线程循环接收客户端连接
// ---------------------------------------------------------
while (true) {
sockaddr_in client_addr{};
// accept() 可能修改 client_len,
// 因此每次调用前都重新初始化。
socklen_t client_len = sizeof(client_addr);
int cfd = accept(
lfd,
reinterpret_cast<sockaddr*>(&client_addr),
&client_len
);
if (cfd == -1) {
// accept() 被信号中断,重新调用即可。
if (errno == EINTR) {
continue;
}
// 客户端在连接完成前异常终止。
if (errno == ECONNABORTED) {
continue;
}
cerr << "accept() failed: "
<< strerror(errno) << '\n';
continue;
}
// -----------------------------------------------------
// 8. 为工作线程准备参数
// -----------------------------------------------------
//
// 不能直接把循环中的局部变量地址传给线程,
// 因为主线程下一轮循环可能马上覆盖这些变量。
//
// 这里为每个连接单独分配一份 ClientInfo。
auto* info = new (nothrow) ClientInfo{
cfd,
client_addr
};
if (info == nullptr) {
cerr << "allocating ClientInfo failed\n";
close(cfd);
continue;
}
// -----------------------------------------------------
// 9. 创建工作线程
// -----------------------------------------------------
pthread_t tid{};
ret = pthread_create(
&tid,
&attr,
do_connect,
info
);
if (ret != 0) {
cerr << "pthread_create() failed: "
<< strerror(ret) << '\n';
close(cfd);
delete info;
continue;
}
// 注意:
//
// 主线程不能在这里 close(cfd)。
//
// 多线程共享同一个文件描述符表。
// 如果主线程关闭 cfd,工作线程使用的同一编号也会受到影响。
//
// 从 pthread_create() 成功后开始,cfd 的所有权
// 完全交给工作线程。
}
// 当前程序是无限循环,下面代码正常情况下不会执行。
pthread_attr_destroy(&attr);
close(lfd);
return 0;
}
// =============================================================
// 工作线程入口函数
// =============================================================
void* do_connect(void* arg) {
// 使用 unique_ptr 接管主线程传入的堆内存。
//
// 当线程函数结束时,即使中途提前 return,
// ClientInfo 也能被自动释放。
unique_ptr<ClientInfo> info(
static_cast<ClientInfo*>(arg)
);
int cfd = info->cfd;
sockaddr_in client_addr = info->client_addr;
char ip_str[INET_ADDRSTRLEN] = {};
if (inet_ntop(
AF_INET,
&client_addr.sin_addr,
ip_str,
sizeof(ip_str)) == nullptr) {
strncpy(
ip_str,
"<invalid-ip>",
sizeof(ip_str) - 1
);
}
uint16_t port = ntohs(client_addr.sin_port);
// pthread_self() 返回 pthread_t,它是 pthread 库使用的线程标识,
// 在标准层面属于不透明类型。
//
// 这里使用 Linux gettid 对应的系统调用获取内核线程 ID。
long tid = get_kernel_tid();
{
ostringstream oss;
oss << '\n'
<< '[' << get_timestamp()
<< "] NEW CONNECTION\n"
<< " Server PID : " << getpid() << '\n'
<< " Worker TID : " << tid << '\n'
<< " Client : "
<< ip_str << ':' << port << '\n'
<< " cfd : " << cfd << "\n\n";
log_message(oss.str());
}
// ---------------------------------------------------------
// 接收客户端一行消息
// ---------------------------------------------------------
//
// 本示例使用"换行符"作为应用层消息边界:
//
// Hello from client 0\n
//
// TCP 本身没有消息边界,不能假设一次 send()
// 一定对应服务端的一次 recv()。
string request;
ssize_t received = recv_line(cfd, request);
if (received > 0) {
{
ostringstream oss;
oss << '[' << get_timestamp()
<< "] [TID:" << tid << "] received "
<< received << " bytes: "
<< request << '\n';
log_message(oss.str());
}
// 模拟耗时业务。
//
// 不同客户端由不同线程处理,因此其他工作线程
// 仍然可以同时运行。
sleep(1);
const string response = "Hello from server\n";
ssize_t sent = send_all(
cfd,
response.data(),
response.size()
);
if (sent == -1) {
ostringstream oss;
oss << '[' << get_timestamp()
<< "] [TID:" << tid
<< "] send failed: "
<< strerror(errno) << '\n';
log_message(oss.str());
} else {
ostringstream oss;
oss << '[' << get_timestamp()
<< "] [TID:" << tid << "] sent "
<< sent
<< " bytes: Hello from server\n";
log_message(oss.str());
}
} else if (received == 0) {
ostringstream oss;
oss << '[' << get_timestamp()
<< "] [TID:" << tid
<< "] client closed without sending data\n";
log_message(oss.str());
} else {
ostringstream oss;
oss << '[' << get_timestamp()
<< "] [TID:" << tid
<< "] recv failed: "
<< strerror(errno) << '\n';
log_message(oss.str());
}
// 每个 cfd 只由其对应工作线程关闭。
close(cfd);
{
ostringstream oss;
oss << '[' << get_timestamp()
<< "] [TID:" << tid
<< "] closed cfd "
<< cfd
<< " and exited\n";
log_message(oss.str());
}
return nullptr;
}
// =============================================================
// 完整发送 len 字节
// =============================================================
ssize_t send_all(int fd, const void* data, size_t len) {
const auto* p = static_cast<const char*>(data);
size_t total = 0;
while (total < len) {
ssize_t n = send(
fd,
p + total,
len - total,
// 对端关闭连接时,避免 SIGPIPE 终止整个进程。
MSG_NOSIGNAL
);
if (n > 0) {
total += static_cast<size_t>(n);
continue;
}
if (n == -1 && errno == EINTR) {
continue;
}
return -1;
}
return static_cast<ssize_t>(total);
}
// =============================================================
// 接收一行,以 '\n' 作为消息边界
// =============================================================
ssize_t recv_line(
int fd,
string& out,
size_t max_len
) {
out.clear();
while (out.size() < max_len) {
char ch = '\0';
ssize_t n = recv(
fd,
&ch,
1,
0
);
if (n > 0) {
if (ch == '\n') {
return static_cast<ssize_t>(out.size());
}
out.push_back(ch);
continue;
}
// recv() 返回 0,表示对端执行了有序关闭。
if (n == 0) {
return static_cast<ssize_t>(out.size());
}
if (errno == EINTR) {
continue;
}
return -1;
}
errno = EMSGSIZE;
return -1;
}
// =============================================================
// 线程安全日志输出
// =============================================================
void log_message(const string& message) {
pthread_mutex_lock(&g_log_mutex);
cout << message << flush;
pthread_mutex_unlock(&g_log_mutex);
}
// =============================================================
// 获取 Linux 内核线程 ID
// =============================================================
long get_kernel_tid() {
return static_cast<long>(
syscall(SYS_gettid)
);
}
// =============================================================
// 获取毫秒级时间戳
// =============================================================
string get_timestamp() {
auto now = chrono::system_clock::now();
time_t current =
chrono::system_clock::to_time_t(now);
auto ms =
chrono::duration_cast<chrono::milliseconds>(
now.time_since_epoch()
) % 1000;
tm local_tm{};
// localtime() 可能使用共享静态存储,不适合多线程并发调用。
// localtime_r() 将结果写入调用者提供的 local_tm。
localtime_r(¤t, &local_tm);
ostringstream oss;
oss << put_time(&local_tm, "%H:%M:%S")
<< '.'
<< setfill('0')
<< setw(3)
<< ms.count();
return oss.str();
}
三、Linux 多线程 TCP 客户端
客户端也使用 pthread 创建多个线程。
每个线程独立完成:
text
socket()
│
connect()
│
send()
│
recv()
│
close()
│
return
虽然每个线程创建自己的套接字,但这些套接字仍然登记在同一个进程级文件描述符表中。
例如三个客户端线程同时运行时,可能得到:
text
客户端进程 PID = 5230
线程 TID=5231 创建 socket,得到 fd=3
线程 TID=5232 创建 socket,得到 fd=4
线程 TID=5233 创建 socket,得到 fd=5
三个线程的 PID 相同,但内核线程 ID 不同。
3.1 完整客户端代码
cpp
// client_thread.cpp
#include <arpa/inet.h>
#include <cerrno>
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <pthread.h>
#include <sstream>
#include <string>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <vector>
using namespace std;
struct ClientArg {
int index;
};
pthread_mutex_t g_log_mutex = PTHREAD_MUTEX_INITIALIZER;
void* run_client(void* arg);
void log_message(const string& message);
long get_kernel_tid();
ssize_t send_all(int fd, const void* data, size_t len);
ssize_t recv_line(int fd, string& out, size_t max_len = 4096);
int main() {
constexpr int kClientNum = 3;
cout << "=== Concurrent pthread clients: "
<< kClientNum
<< " ===\n";
// 提前确定 vector 大小,之后不再扩容。
//
// 因此 &args[i] 在线程运行期间地址保持稳定。
vector<pthread_t> tids(kClientNum);
vector<ClientArg> args(kClientNum);
// 记录哪些线程创建成功。
//
// pthread_create() 失败时,对应 tids[i] 不可用于 pthread_join()。
vector<bool> created(kClientNum, false);
// ---------------------------------------------------------
// 创建客户端线程
// ---------------------------------------------------------
for (int i = 0; i < kClientNum; ++i) {
args[i].index = i;
int ret = pthread_create(
&tids[i],
nullptr,
run_client,
&args[i]
);
if (ret != 0) {
cerr << "pthread_create() failed for client "
<< i
<< ": "
<< strerror(ret)
<< '\n';
continue;
}
created[i] = true;
}
// ---------------------------------------------------------
// 等待所有成功创建的线程结束
// ---------------------------------------------------------
for (int i = 0; i < kClientNum; ++i) {
if (!created[i]) {
continue;
}
int ret = pthread_join(
tids[i],
nullptr
);
if (ret != 0) {
cerr << "pthread_join() failed for client "
<< i
<< ": "
<< strerror(ret)
<< '\n';
}
}
cout << "=== All clients finished ===\n";
return 0;
}
// =============================================================
// 客户端线程入口
// =============================================================
void* run_client(void* arg) {
const auto* client_arg =
static_cast<const ClientArg*>(arg);
int index = client_arg->index;
long tid = get_kernel_tid();
// 制造少量错峰。
//
// 不使用全局 rand(),避免多个线程操作同一个随机数状态。
usleep(
static_cast<useconds_t>(
(index % 2) * 200000
)
);
// ---------------------------------------------------------
// 1. 创建客户端套接字
// ---------------------------------------------------------
int fd = socket(
AF_INET,
SOCK_STREAM,
0
);
if (fd == -1) {
ostringstream oss;
oss << "Client "
<< index
<< " socket() failed: "
<< strerror(errno)
<< '\n';
log_message(oss.str());
return nullptr;
}
// ---------------------------------------------------------
// 2. 设置服务器地址
// ---------------------------------------------------------
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
if (inet_pton(
AF_INET,
"127.0.0.1",
&server_addr.sin_addr) != 1) {
log_message("inet_pton() failed\n");
close(fd);
return nullptr;
}
// ---------------------------------------------------------
// 3. 连接服务器
// ---------------------------------------------------------
if (connect(
fd,
reinterpret_cast<sockaddr*>(&server_addr),
sizeof(server_addr)) == -1) {
ostringstream oss;
oss << "Client "
<< index
<< " connect() failed: "
<< strerror(errno)
<< '\n';
log_message(oss.str());
close(fd);
return nullptr;
}
{
ostringstream oss;
oss << "Client "
<< index
<< " connected: PID="
<< getpid()
<< ", TID="
<< tid
<< ", fd="
<< fd
<< '\n';
log_message(oss.str());
}
// ---------------------------------------------------------
// 4. 发送请求
// ---------------------------------------------------------
//
// 使用 '\n' 作为应用层消息结束标记。
string request =
"Hello from client "
+ to_string(index)
+ "\n";
if (send_all(
fd,
request.data(),
request.size()) == -1) {
ostringstream oss;
oss << "Client "
<< index
<< " send failed: "
<< strerror(errno)
<< '\n';
log_message(oss.str());
close(fd);
return nullptr;
}
{
ostringstream oss;
oss << "Client "
<< index
<< " sent: Hello from client "
<< index
<< '\n';
log_message(oss.str());
}
// ---------------------------------------------------------
// 5. 接收服务器响应
// ---------------------------------------------------------
string response;
ssize_t n = recv_line(
fd,
response
);
if (n > 0) {
ostringstream oss;
oss << "Client "
<< index
<< " received: "
<< response
<< '\n';
log_message(oss.str());
} else if (n == 0) {
ostringstream oss;
oss << "Client "
<< index
<< " peer closed connection\n";
log_message(oss.str());
} else {
ostringstream oss;
oss << "Client "
<< index
<< " recv failed: "
<< strerror(errno)
<< '\n';
log_message(oss.str());
}
// ---------------------------------------------------------
// 6. 关闭套接字
// ---------------------------------------------------------
close(fd);
{
ostringstream oss;
oss << "Client "
<< index
<< " closed fd "
<< fd
<< '\n';
log_message(oss.str());
}
return nullptr;
}
// =============================================================
// 完整发送
// =============================================================
ssize_t send_all(int fd, const void* data, size_t len) {
const auto* p =
static_cast<const char*>(data);
size_t total = 0;
while (total < len) {
ssize_t n = send(
fd,
p + total,
len - total,
MSG_NOSIGNAL
);
if (n > 0) {
total += static_cast<size_t>(n);
continue;
}
if (n == -1 && errno == EINTR) {
continue;
}
return -1;
}
return static_cast<ssize_t>(total);
}
// =============================================================
// 接收一行
// =============================================================
ssize_t recv_line(
int fd,
string& out,
size_t max_len
) {
out.clear();
while (out.size() < max_len) {
char ch = '\0';
ssize_t n = recv(
fd,
&ch,
1,
0
);
if (n > 0) {
if (ch == '\n') {
return static_cast<ssize_t>(out.size());
}
out.push_back(ch);
continue;
}
if (n == 0) {
return static_cast<ssize_t>(out.size());
}
if (errno == EINTR) {
continue;
}
return -1;
}
errno = EMSGSIZE;
return -1;
}
// =============================================================
// 日志互斥
// =============================================================
void log_message(const string& message) {
pthread_mutex_lock(&g_log_mutex);
cout << message << flush;
pthread_mutex_unlock(&g_log_mutex);
}
// =============================================================
// 获取 Linux 内核线程 ID
// =============================================================
long get_kernel_tid() {
return static_cast<long>(
syscall(SYS_gettid)
);
}
四、编译和运行
4.1 编译服务端
bash
g++ -std=c++17 \
-Wall \
-Wextra \
-Wpedantic \
-pthread \
server_thread.cpp \
-o server_thread
4.2 编译客户端
bash
g++ -std=c++17 \
-Wall \
-Wextra \
-Wpedantic \
-pthread \
client_thread.cpp \
-o client_thread
推荐使用:
bash
-pthread
而不是只写:
bash
-lpthread
-pthread 不仅参与链接,还可能启用编译器和标准库中与线程相关的编译配置。
4.3 启动服务端
bash
./server_thread
服务端输出:
text
Server listening on 0.0.0.0:8888, PID=2111
4.4 启动多线程客户端
在另一个终端执行:
bash
./client_thread
客户端可能输出:
text
=== Concurrent pthread clients: 3 ===
Client 0 connected: PID=2113, TID=2114, fd=3
Client 2 connected: PID=2113, TID=2116, fd=4
Client 0 sent: Hello from client 0
Client 2 sent: Hello from client 2
Client 1 connected: PID=2113, TID=2115, fd=5
Client 1 sent: Hello from client 1
Client 0 received: Hello from server
Client 0 closed fd 3
Client 2 received: Hello from server
Client 2 closed fd 4
Client 1 received: Hello from server
Client 1 closed fd 5
=== All clients finished ===
可以观察到:
text
三个客户端线程的 PID 相同:
PID = 2113
但是 TID 不同:
TID = 2114
TID = 2115
TID = 2116
这正是多线程模型的特征。
五、多线程服务端中的 fd 到底如何共享
这是多进程与多线程转换中最容易混淆的部分。
5.1 多进程中的文件描述符
调用 fork() 后,父子进程各自拥有一份文件描述符表。
text
fork() 之前:
父进程 fd 表
┌──────┬───────────────────┐
│ fd=3 │ 监听 socket │
├──────┼───────────────────┤
│ fd=4 │ 客户端连接 socket │
└──────┴───────────────────┘
fork() 之后:
父进程 fd 表 子进程 fd 表
┌──────┬──────────────┐ ┌──────┬──────────────┐
│ fd=3 │ 监听 socket │ │ fd=3 │ 监听 socket │
├──────┼──────────────┤ ├──────┼──────────────┤
│ fd=4 │ 连接 socket │ │ fd=4 │ 连接 socket │
└──────┴──────────────┘ └──────┴──────────────┘
需要注意:
父子进程虽然拥有不同的文件描述符表,但对应表项仍然引用同一个底层 socket 对象。
因此:
cpp
// 父进程
close(cfd);
只会关闭父进程文件描述符表中的这一个引用。
子进程中的 cfd 仍然有效。
只有所有引用都关闭后,内核才真正释放底层 socket。
所以多进程服务端必须这样写:
cpp
if (pid == 0) {
// 子进程不负责监听。
close(lfd);
do_connect(cfd);
close(cfd);
_exit(0);
} else {
// 父进程不负责当前客户端通信。
close(cfd);
}
5.2 多线程中的文件描述符
同一进程的所有线程共享同一张文件描述符表。
text
一个进程
┌──────────────────────────────────────┐
│ 文件描述符表 │
│ │
│ fd=3 ──► 监听 socket │
│ fd=4 ──► 客户端1 socket │
│ fd=5 ──► 客户端2 socket │
│ │
│ 主线程 ─────────┐ │
│ 工作线程1 ──────┼── 共享同一张 fd 表 │
│ 工作线程2 ──────┘ │
└──────────────────────────────────────┘
因此,下面的写法是错误的:
cpp
pthread_create(&tid, nullptr, do_connect, info);
// 错误:工作线程也需要使用同一个 cfd。
close(cfd);
主线程中的 close(cfd) 会直接从整个进程的文件描述符表中删除该描述符。
工作线程随后再执行:
cpp
recv(cfd, ...);
可能遇到:
text
EBADF:Bad file descriptor
更严重的是,如果该文件描述符编号已经被其他线程重新分配,工作线程甚至可能错误操作另一个完全无关的文件。
正确做法是明确所有权:
text
accept() 返回 cfd
│
▼
主线程将 cfd 交给工作线程
│
▼
主线程不再使用、不再关闭 cfd
│
▼
工作线程通信结束后 close(cfd)
六、多进程与多线程的内存模型差异
6.1 多进程模型
text
父进程
┌──────────────────────────────┐
│ 独立代码映射 │
│ 独立虚拟地址空间 │
│ 独立全局变量 │
│ 独立堆 │
│ 独立栈 │
│ 独立文件描述符表 │
└──────────────────────────────┘
子进程
┌──────────────────────────────┐
│ 独立代码映射 │
│ 独立虚拟地址空间 │
│ 独立全局变量 │
│ 独立堆 │
│ 独立栈 │
│ 独立文件描述符表 │
└──────────────────────────────┘
fork() 并不会立即把父进程的所有物理内存完整复制一遍。
Linux 通常使用写时复制,即 Copy-on-Write:
text
刚 fork() 时:
父进程虚拟页 ──┐
├──► 同一物理页,只读共享
子进程虚拟页 ──┘
某一方执行写操作后:
父进程虚拟页 ─────► 原物理页
子进程虚拟页 ─────► 新复制的物理页
因此,"fork() 会立即复制整个进程内存"是不准确的。
更准确的说法是:
fork()创建一套新的进程执行上下文和虚拟内存映射,用户空间内存页通常通过写时复制延迟复制。
6.2 多线程模型
text
一个进程
┌────────────────────────────────────────┐
│ 共享代码段 │
│ 共享全局变量 │
│ 共享静态变量 │
│ 共享堆 │
│ 共享文件描述符表 │
│ 共享当前工作目录等进程级资源 │
│ │
│ 线程1 │
│ ├── 独立寄存器上下文 │
│ ├── 独立用户栈 │
│ ├── 独立内核栈 │
│ ├── 独立线程局部存储 TLS │
│ └── 独立内核线程 ID │
│ │
│ 线程2 │
│ ├── 独立寄存器上下文 │
│ ├── 独立用户栈 │
│ ├── 独立内核栈 │
│ ├── 独立线程局部存储 TLS │
│ └── 独立内核线程 ID │
└────────────────────────────────────────┘
多线程并不是"所有内容都共享"。
线程之间独立的内容主要包括:
-
CPU 寄存器状态;
-
程序计数器;
-
用户栈;
-
内核栈;
-
线程局部存储;
-
调度状态;
-
内核线程 ID;
-
每线程信号掩码;
-
errno。
在支持 pthread 的系统中,errno 通常被实现为线程局部存储,因此不同线程可以同时拥有不同的 errno 值。
七、参数传递方式的差异
7.1 多进程中的循环变量
多进程代码:
cpp
for (int i = 0; i < 3; ++i) {
pid_t pid = fork();
if (pid == 0) {
run_client(i);
_exit(0);
}
}
fork() 返回后,子进程拥有当前变量 i 的独立地址空间副本。
因此每个子进程看到的是创建时的 i 值。
7.2 多线程中的错误写法
下面的线程代码存在竞态:
cpp
for (int i = 0; i < 3; ++i) {
pthread_create(
&tid,
nullptr,
run_client,
&i
);
}
三个线程得到的都是同一个地址:
text
线程1 arg ──┐
线程2 arg ──┼──► 循环变量 i
线程3 arg ──┘
主线程不断修改 i,工作线程何时读取 i 又由调度器决定。
因此线程可能读到:
text
1
2
3
3
甚至在线程启动时循环已经结束,所有线程都读到 3。
7.3 正确方式一:为每个线程分配独立参数
服务端适合使用:
cpp
ClientInfo* info = new ClientInfo{
cfd,
client_addr
};
pthread_create(
&tid,
nullptr,
do_connect,
info
);
工作线程接管并释放:
cpp
unique_ptr<ClientInfo> info(
static_cast<ClientInfo*>(arg)
);
7.4 正确方式二:使用地址稳定的参数数组
客户端可以提前创建固定大小数组:
cpp
vector<ClientArg> args(CLIENT_NUM);
for (int i = 0; i < CLIENT_NUM; ++i) {
args[i].index = i;
pthread_create(
&tids[i],
nullptr,
run_client,
&args[i]
);
}
这里安全的前提是:
-
args已经提前确定大小; -
创建线程后不再执行会导致
vector扩容的操作; -
主线程在所有子线程结束前不销毁
args; -
主线程使用
pthread_join()等待线程。
所以"pthread 参数必须使用 new"并不准确。
准确说法是:
传给线程的参数对象必须在线程读取期间保持有效,并且不能被其他线程无保护地覆盖。
八、线程回收与进程回收
8.1 子进程回收
子进程退出后,内核仍然需要保留一部分信息,例如:
-
PID;
-
退出状态;
-
资源统计信息。
这些信息必须由父进程通过 wait() 或 waitpid() 读取。
如果父进程一直不回收,子进程会成为僵尸进程。
cpp
while (waitpid(-1, nullptr, WNOHANG) > 0) {
}
在正式代码中,通常应优先使用 sigaction(),而不是简单的 signal()。
8.2 线程回收
线程不会成为传统意义上的僵尸进程,但可连接线程结束后仍会保留部分线程资源。
线程有两种管理方式。
方式一:连接线程
创建:
cpp
pthread_create(
&tid,
nullptr,
run_client,
arg
);
回收:
cpp
pthread_join(
tid,
nullptr
);
适用于:
-
客户端测试程序;
-
主线程需要等待工作结果;
-
需要获取线程返回值;
-
需要保证所有线程完成后再退出。
方式二:分离线程
可以在线程创建后调用:
cpp
pthread_detach(tid);
也可以在创建前设置线程属性:
cpp
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(
&attr,
PTHREAD_CREATE_DETACHED
);
pthread_create(
&tid,
&attr,
do_connect,
arg
);
适用于一连接一线程的服务端。
分离线程结束后,线程资源自动释放,不能再对它调用 pthread_join()。
九、为什么日志输出需要互斥锁
多个线程可能同时执行:
cpp
cout << "Client " << index
<< " connected, fd=" << fd
<< endl;
一条日志实际上包含多个输出操作。
执行过程可能变成:
text
线程1输出:"Client "
线程2输出:"Client "
线程1输出:"0"
线程2输出:"1"
线程1输出:" connected"
线程2输出:" connected"
最终日志可能是:
text
Client Client 01 connected connected
因此应先把完整日志拼接到字符串中,再持锁输出:
cpp
void log_message(const string& message) {
pthread_mutex_lock(&g_log_mutex);
cout << message << flush;
pthread_mutex_unlock(&g_log_mutex);
}
锁的范围只覆盖最终输出,不覆盖耗时业务处理。
错误做法:
cpp
pthread_mutex_lock(&mutex);
recv(cfd, ...);
sleep(1);
send(cfd, ...);
pthread_mutex_unlock(&mutex);
这样所有工作线程会被串行化,失去并发意义。
十、为什么不能直接假设一次 write 对应一次 read
TCP 是字节流协议,不保留应用层消息边界。
客户端执行:
cpp
write(fd, "Hello", 5);
write(fd, "World", 5);
服务端可能读取为:
text
第一次 read:HelloWorld
也可能是:
text
第一次 read:Hel
第二次 read:loWor
第三次 read:ld
同样,下面的调用也不能保证一次发送完毕:
cpp
write(fd, buf, len);
当发送缓冲区空间不足时,write() 或 send() 可能只发送部分数据。
因此代码中增加了 send_all():
cpp
while (total < len) {
ssize_t n = send(
fd,
data + total,
len - total,
MSG_NOSIGNAL
);
total += n;
}
本示例使用换行符划分应用层消息:
text
Hello from client 0\n
服务端持续接收,直到遇到 \n。
实际项目中常见的消息边界方案包括:
-
固定长度;
-
特殊分隔符;
-
长度字段加消息体;
-
TLV;
-
HTTP 协议格式;
-
Protobuf 消息头;
-
WebSocket 帧。
十一、为什么使用 MSG_NOSIGNAL
假设客户端已经关闭连接,而服务端仍然调用:
cpp
write(cfd, data, len);
Linux 可能向进程发送 SIGPIPE。
SIGPIPE 的默认行为是终止整个进程。
对于多线程服务端,这一点尤其危险:
text
一个工作线程向已关闭连接发送数据
│
▼
触发 SIGPIPE
│
▼
整个服务端进程终止
│
▼
所有工作线程一起退出
因此代码使用:
cpp
send(
fd,
data,
len,
MSG_NOSIGNAL
);
也可以在进程级忽略 SIGPIPE:
cpp
signal(SIGPIPE, SIG_IGN);
但 MSG_NOSIGNAL 的影响范围仅限当前 send() 调用,更容易控制。
十二、原多线程草稿中的具体问题
12.1 include 缺少 #
错误:
cpp
include <arpa/inet.h>
正确:
cpp
#include <arpa/inet.h>
12.2 sockarr 拼写错误
错误:
cpp
accept(
lfd,
(sockarr*)&client_addr,
&len
);
正确类型是:
cpp
sockaddr
正确写法:
cpp
accept(
lfd,
reinterpret_cast<sockaddr*>(&client_addr),
&len
);
12.3 换行符写错
错误:
cpp
cout << "n[";
这里输出的是普通字符 n。
正确:
cpp
cout << "\n[";
12.4 字符串结束符写错
错误:
cpp
buf[n] = '0';
'0' 是字符数字零,其 ASCII 值通常是 48。
正确的字符串结束符是:
cpp
buf[n] = '\0';
12.5 创建失败后仍然 join
错误逻辑:
cpp
pthread_t tids[CLIENT_NUM];
for (...) {
pthread_create(&tids[i], ...);
}
for (...) {
pthread_join(tids[i], nullptr);
}
如果某次 pthread_create() 失败,对应的 tids[i] 没有有效线程标识。
随后调用:
cpp
pthread_join(tids[i], nullptr);
行为不正确。
所以需要记录创建状态:
cpp
vector<bool> created(CLIENT_NUM, false);
只等待成功创建的线程:
cpp
if (created[i]) {
pthread_join(tids[i], nullptr);
}
12.6 pthread 错误码处理错误
socket()、accept() 等系统调用通常返回 -1,并设置 errno。
但很多 pthread 函数直接返回错误码:
cpp
int ret = pthread_create(...);
if (ret != 0) {
cerr << strerror(ret) << endl;
}
不能简单写成:
cpp
cerr << strerror(errno) << endl;
因为 errno 可能仍然是之前某个系统调用留下的值。
12.7 pthread_self() 不等于可直接打印的 Linux TID
pthread_self() 返回:
cpp
pthread_t
它是 pthread 库使用的不透明线程标识。
Linux 内核线程 ID 可以通过:
cpp
syscall(SYS_gettid)
获得。
例如:
cpp
cout << "PID=" << getpid()
<< ", TID=" << syscall(SYS_gettid)
<< endl;
同一进程中的线程表现为:
text
主线程:PID=3000,TID=3000
线程1: PID=3000,TID=3001
线程2: PID=3000,TID=3002
主线程的 TID 通常等于进程 PID,其他线程的 TID 与 PID 不同。
12.8 rand() 不适合作为线程共享状态示例
原代码:
cpp
sleep(rand() % 2);
多个线程同时操作同一个伪随机数状态,会形成共享状态问题,也使行为难以分析。
演示程序可根据线程编号制造错峰:
cpp
usleep((index % 2) * 200000);
正式程序可以为每个线程创建独立的随机数生成器。
十三、多进程与多线程核心差异表
| 对比项 | 多进程 fork() |
多线程 pthread_create() |
|---|---|---|
| PID | 每个进程不同 | 所有线程的 PID 相同 |
| TID | 每个进程主线程通常有自己的 TID | 每个线程拥有不同 TID |
| 地址空间 | 每个进程独立 | 所有线程共享 |
| 全局变量 | 每个进程各有一份 | 所有线程共享 |
| 堆区 | 独立 | 共享 |
| 栈 | 每个进程独立 | 每个线程独立 |
| 文件描述符表 | fork() 后形成独立表副本 |
所有线程共享同一张表 |
| 底层 socket | 父子进程描述符可引用同一 socket | 线程通过共享 fd 表访问 socket |
| 参数传递 | 子进程直接得到变量快照 | 通常通过 void* 传递有效地址 |
| 数据同步 | 普通变量天然隔离 | 共享数据需要 mutex、atomic 等 |
| 资源回收 | wait()、waitpid() |
pthread_join() 或 detach |
| 僵尸问题 | 子进程可能成为僵尸进程 | 没有僵尸进程,但 joinable 线程仍需回收 |
| 通信方式 | 管道、共享内存、socket 等 IPC | 可以直接访问共享内存 |
| 故障隔离 | 子进程崩溃通常不直接杀死父进程 | 一个线程发生致命错误通常终止整个进程 |
| 创建成本 | 通常更高,但有写时复制 | 通常更低 |
| 切换成本 | 通常需要切换地址空间 | 同进程线程通常不切换地址空间 |
| 编程复杂度 | IPC 和进程管理复杂 | 数据竞争、死锁和生命周期管理复杂 |
十四、Linux 内核视角:进程和线程并非完全不同
在 Linux 内核中,无论进程还是线程,最终都使用可调度任务表示。
可以概念化为:
text
task_struct
│
├── 调度信息
├── 寄存器上下文
├── PID/TID
├── 内存描述
├── 文件描述符表
├── 信号处理信息
└── 其他资源
创建传统进程时,新任务通常不共享:
text
地址空间
文件描述符表
信号处理结构
创建线程时,新任务会共享更多资源:
text
共享地址空间
共享文件描述符表
共享文件系统状态
共享信号处理结构
因此,Linux 线程通常被称为轻量级进程,但"线程只是一个普通进程"这种说法也不够准确。
更准确的描述是:
Linux 将进程和线程统一表示为可调度任务;两者的核心区别,是创建任务时选择共享哪些资源。
十五、多线程不一定在所有场景下都优于多进程
不能简单得出:
text
多线程一定比多进程快
线程通常具有以下优势:
-
创建成本较低;
-
内存占用较低;
-
共享数据方便;
-
线程之间传递数据不需要传统 IPC;
-
同地址空间切换通常更轻量。
但线程也存在明显风险:
-
全局变量可能产生数据竞争;
-
一个线程越界写可能破坏整个进程;
-
一个线程触发段错误可能导致所有线程退出;
-
一个线程死锁可能阻塞整个服务;
-
文件描述符误关闭会影响其他线程;
-
线程数量过多会消耗大量栈空间和调度资源。
多进程的优势主要是:
-
地址空间天然隔离;
-
单个工作进程崩溃时故障范围较小;
-
更容易限制单个任务的资源和权限;
-
适合执行不可信或高风险任务;
-
可结合 Namespace、Seccomp 等机制进行强隔离。
十六、一连接一线程模型的扩展性边界
当前代码属于:
text
一个客户端连接
│
▼
创建一个工作线程
这种模型适合:
-
学习 pthread;
-
理解阻塞 socket;
-
中小规模并发;
-
每个连接业务逻辑较简单;
-
内网管理工具;
-
并发连接数量可控的服务。
但不适合直接支撑极高并发。
假设每个线程默认栈空间为数 MB,即使很多页面没有立即实际占用物理内存,大量线程仍会带来:
-
大量虚拟地址空间占用;
-
线程控制块开销;
-
内核调度压力;
-
上下文切换增加;
-
锁竞争增加;
-
调试难度增加。
更常见的生产模型包括:
text
模型一:固定线程池
主线程 accept
│
▼
任务队列
│
├── 工作线程1
├── 工作线程2
├── 工作线程3
└── 工作线程N
以及:
text
模型二:epoll + 线程池
网络线程
│
epoll_wait()
│
处理非阻塞 I/O
│
耗时任务提交到线程池
或者:
text
模型三:多进程 + epoll
Master 进程
│
├── Worker 进程1:epoll 事件循环
├── Worker 进程2:epoll 事件循环
└── Worker 进程N:epoll 事件循环
Nginx 的典型架构更接近:
text
Master 进程
│
├── Worker 进程
├── Worker 进程
└── Worker 进程
每个 Worker 通常通过事件驱动方式处理大量连接,而不是简单地为每个客户端创建一个线程。某些阻塞任务可以使用线程池,但这不等于 Nginx 的核心模型是"一连接一线程"。
十七、最终总结
多进程模型可以理解为:
text
不同公司
每家公司:
- 有自己的办公空间
- 有自己的账本
- 有自己的员工
- 一家公司出事故,其他公司通常不直接受影响
- 公司之间交换信息需要正式通信渠道
多线程模型可以理解为:
text
同一家公司的多个员工
所有员工:
- 共享办公室
- 共享文件柜
- 共享公司账户
- 每个人有自己的工作桌和工作记录
- 交流成本低
- 但必须明确权限、所有权和同步规则
对于本例中的 TCP 服务端:
text
多进程:
父进程 accept
│
▼
fork 子进程
│
├── 父进程 close(cfd)
└── 子进程 close(lfd),处理 cfd
多线程:
主线程 accept
│
▼
pthread_create 工作线程
│
├── 主线程保留 lfd
├── 主线程不再操作 cfd
└── 工作线程处理并最终 close(cfd)
最关键的区别是:
多进程中的父子进程拥有独立的文件描述符表副本;多线程中的所有线程共享同一个进程级文件描述符表。
因此,多线程版本必须明确规定:
text
一个 cfd 只能有一个明确的所有者
在当前实现中:
text
accept() 成功后:
主线程暂时拥有 cfd
pthread_create() 成功后:
所有权转移给工作线程
工作线程通信结束后:
工作线程 close(cfd)
只要遵守这一所有权规则,并正确处理线程参数生命周期、日志同步、部分发送、TCP 消息边界和线程回收,就可以得到一个结构清晰、可编译运行的 Linux pthread 并发 TCP 服务端。