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 服务端。

相关推荐
Peregrine926 分钟前
C++ - > 类和对象
java·c++·算法
一条泥憨鱼28 分钟前
【从0开始学习计算机网络】| ARP 协议:地址解析,ARP 欺骗,链路层寻址
网络·后端·学习·计算机网络
2501_9339232532 分钟前
SpringCloud组件:Eureka服务注册与发现
java·开发语言·后端·spring cloud·eureka
Wang's Blog34 分钟前
Java 中间件之 RabbitMQ 快速入门: SpringAMQP 消息转换器
java·中间件·java-rabbitmq
m0_7156744335 分钟前
数据分类分级的范式转换:从规则匹配到场景化高准确率一键部署
大数据·网络·人工智能·网络安全
Re_zero1 小时前
一次 SSE 断流排查:PrintWriter 吞掉了我的 IOException
java·后端
Tech探索者1 小时前
没有公网 IP、不会端口映射?用 Natfrp 轻松远程访问家中 NAS
网络协议·tcp/ip·智能路由器
我是人✓1 小时前
Java核心语法:作用域、循环与大数值处理
java·开发语言·后端
dog2502 小时前
带宽,时延,抖动的不可能三角
网络·tcp/ip
夜之眷属2 小时前
PriorityQueue:for-each 遍历和 poll 取出,顺序一样吗?
java·服务器·数据结构·后端·算法