6.多线程的TCP通信

文章目录

  • [Linux 多线程 TCP 服务端与客户端:基于 pthread 的完整实现与多进程对比](#Linux 多线程 TCP 服务端与客户端:基于 pthread 的完整实现与多进程对比)
  • [二、Linux 多线程 TCP 服务端](#二、Linux 多线程 TCP 服务端)
    • [2.1 服务端职责划分](#2.1 服务端职责划分)
    • [2.2 完整服务端代码](#2.2 完整服务端代码)
  • [三、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 服务端中,主进程负责:

  1. 创建监听套接字;

  2. 调用 accept() 接收客户端连接;

  3. 每建立一个连接,就调用 fork() 创建子进程;

  4. 子进程负责与对应客户端通信;

  5. 父进程通过 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(&current, &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 服务端。

相关推荐
AA陈超9 小时前
004 T02 - 俯视角摄像机系统 设计文档
网络·c++·ue5·虚幻引擎
sun032210 小时前
【笔记】Spring MVC 相关介绍
java·spring·mvc
兰令水10 小时前
hot100【acm版】【2026.7.18打卡-java版本】
java·开发语言·算法
CoderYanger11 小时前
A.每日一题:3020. 子集中元素的最大数量
java·程序人生·算法·leetcode·面试·职场和发展·学习方法
wear工程师11 小时前
CompletableFuture 的 allOf 到底怎么收结果?面试别只说并行
java·后端
倒流时光三十年11 小时前
Logback 系列(3):三大核心组件 Logger / Appender / Encoder
java·开发语言·logback
什巳12 小时前
JAVA练习312- 二叉搜索树中第 K 小的元素
java·数据结构·算法·leetcode
智灵鸟科技12 小时前
封闭网络怎么安全地接外部能力:三条通道穷举、威胁封堵矩阵与残余风险
网络·安全·矩阵
信仰87412 小时前
HCIA-华为数通基础理论与实践10
网络·华为·智能路由器