资讯动态

C++ Thread实现简单的socket多线程通信

发布时间:2026/10/8 19:46:01 来源:尧图企业网站定制
前言用std::thread实现 socket 多线程通信的经典做法是一连接一线程主线程反复accept每接到一个连接就交给新线程处理自己回去等下一个。连接数不大、存活时间较长时它足够好用。两个前提先说清。第一socket 不是 C 标准库的东西而是操作系统的 API本文用 POSIX sockets头文件是sys/socket.h、netinet/in.h、unistd.h只能在 Linux / macOS / BSD 上编译Windows 的对应物是 Winsock2要WSAStartup套接字类型是SOCKET而不是int关闭用closesocket并链接ws2_32。第二std::thread会把参数按值拷贝一份到线程自己的存储里这条规则和文件描述符fd配合得刚好——fd 只是int编号拷贝的正是我们要的语义可同样的规则用在想传引用的场合就会踩坑必须用std::ref用了它就得回答被引用的对象活得比线程久吗。一、一连接一线程的服务端主循环socket()建监听套接字 →setsockopt(SO_REUSEADDR)允许重启后立刻绑定 →bind()listen()→ 循环accept()每返回一个连接 fd 就emplace_back一个新线程 → 退出信号到来后唤醒工作线程并逐个join。两个容易忽略的细节其一accept返回的连接 fd 与监听 fd 是两个不同的描述符谁accept出来的就由谁负责close。本文把连接 fd 按值拷贝进工作线程、由线程内的 RAII 守卫关闭主线程绝不能再close同一个 fd——那会造成双重关闭而 fd 编号会被立刻回收重用第二次close可能关掉一个无关的套接字。其二recv返回 0 不是错误而是对端关闭了发送方向返回 -1 才是出错把 0 当成读到空数据继续循环会得到永不退出的忙等。// server.cpp —— 仅适用于 Linux/POSIX // 编译g -stdc17 -Wall -Wextra -pthread server.cpp -o server #include arpa/inet.h #include netinet/in.h #include sys/socket.h #include unistd.h #include algorithm #include cerrno #include csignal #include cstddef #include cstdio #include cstring #include iostream #include mutex #include string #include thread #include vector namespace { // fd 的 RAII 守卫离开作用域自动 close禁止拷贝 class UniqueFd { public: explicit UniqueFd(int fd) noexcept : fd_(fd) {} ~UniqueFd() { if (fd_ 0) ::close(fd_); } // 关闭失败通常无需处理 UniqueFd(const UniqueFd) delete; UniqueFd operator(const UniqueFd) delete; int get() const noexcept { return fd_; } bool valid() const noexcept { return fd_ 0; } private: int fd_ -1; }; volatile std::sig_atomic_t g_stop 0; // 信号处理函数里唯一可安全写的东西 std::mutex g_log_mutex; std::mutex g_conn_mutex; std::vectorint g_conn_fds; // 在服务的连接 fd受 g_conn_mutex 保护 void on_sigint(int) { g_stop 1; } void log_line(const std::string msg) { std::lock_guardstd::mutex lk(g_log_mutex); std::cout msg \n std::flush; } void register_conn(int fd) { std::lock_guardstd::mutex lk(g_conn_mutex); g_conn_fds.push_back(fd); } void unregister_conn(int fd) { std::lock_guardstd::mutex lk(g_conn_mutex); const auto it std::find(g_conn_fds.begin(), g_conn_fds.end(), fd); if (it ! g_conn_fds.end()) g_conn_fds.erase(it); } // TCP 是字节流send 可能只写出去一部分必须循环到写完 bool send_all(int fd, const char* data, std::size_t len) { std::size_t sent 0; while (sent len) { const ssize_t n ::send(fd, data sent, len - sent, MSG_NOSIGNAL); if (n 0) { if (errno EINTR) continue; // 被信号打断重试 return false; } sent static_caststd::size_t(n); } return true; } // 每个连接一个线程把收到的内容原样回显 void handle_client(int raw_fd) { UniqueFd fd(raw_fd); // 由本线程负责关闭 char buf[1024]; for (;;) { const ssize_t n ::recv(fd.get(), buf, sizeof(buf), 0); if (n 0) { if (errno EINTR) continue; log_line(std::string(recv 失败: ) std::strerror(errno)); break; } if (n 0) { log_line(对端关闭连接); break; } // 对端发了 FIN if (!send_all(fd.get(), buf, static_caststd::size_t(n))) { log_line(send 失败); break; } } unregister_conn(fd.get()); // 先摘登记再让 UniqueFd 去 close } } // namespace int main() { std::signal(SIGPIPE, SIG_IGN); // SIGPIPE 默认会终止进程详见坑 4 // glibc 的 signal() 带 SA_RESTART被打断的 accept 会自动重启、拿不到 EINTR // 想让 CtrlC 能中断它得用 sigaction 并关掉 SA_RESTART struct sigaction sa {}; sa.sa_handler on_sigint; ::sigemptyset(sa.sa_mask); sa.sa_flags 0; if (::sigaction(SIGINT, sa, nullptr) 0) { std::perror(sigaction); return 1; } UniqueFd listen_fd(::socket(AF_INET, SOCK_STREAM, 0)); if (!listen_fd.valid()) { std::perror(socket); return 1; } int yes 1; if (::setsockopt(listen_fd.get(), SOL_SOCKET, SO_REUSEADDR, yes, sizeof(yes)) 0) { std::perror(setsockopt); return 1; } sockaddr_in addr {}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听本机所有网卡 addr.sin_port htons(9000); // sockaddr_in 到 sockaddr 的 reinterpret_cast 是 POSIX 惯例两者共享前导布局 if (::bind(listen_fd.get(), reinterpret_castsockaddr*(addr), sizeof(addr)) 0) { std::perror(bind); return 1; } if (::listen(listen_fd.get(), 16) 0) { std::perror(listen); return 1; } log_line(listening on 0.0.0.0:9000); std::vectorstd::thread workers; while (g_stop 0) { sockaddr_in peer {}; socklen_t peer_len sizeof(peer); const int conn ::accept(listen_fd.get(), reinterpret_castsockaddr*(peer), peer_len); if (conn 0) { if (errno EINTR) continue; // 被 SIGINT 打断回 while 判 g_stop std::perror(accept); break; } char ip[INET_ADDRSTRLEN] {0}; ::inet_ntop(AF_INET, peer.sin_addr, ip, sizeof(ip)); log_line(std::string(新连接来自 ) ip : std::to_string(ntohs(peer.sin_port))); register_conn(conn); // conn 按值拷贝进线程线程拿到同一个 fd 编号的副本主线程此后不再碰它 workers.emplace_back(handle_client, conn); } // 优雅退出先 shutdown 让阻塞在 recv 上的工作线程醒过来 { std::lock_guardstd::mutex lk(g_conn_mutex); for (int fd : g_conn_fds) ::shutdown(fd, SHUT_RDWR); } for (std::thread t : workers) { if (t.joinable()) t.join(); } log_line(server exit); return 0; }二、std::thread传参按值拷贝与std::refstd::thread的构造函数会把可调用对象和所有参数各自decay 拷贝decay-copy一份存进线程自己的存储再在工作线程上用这些副本去调用。decay 就是普通的值传递规则数组退化成指针、函数退化成函数指针、顶层const被去掉、引用被拷贝成值。实参形式线程内实际拿到的东西结果t(f, x)x是intint副本以右值形式传给f形参写成int会编译失败右值绑不到非 const 左值引用t(f, x)形参是const int同上能编译但引用绑的是线程内的副本不是原来的xt(f, std::ref(x))std::reference_wrapperint调用时还原成int真正共享同一个对象但要保证x活过t.join()t(f, std::cref(x))reference_wrapperconst int只读共享#include iostream #include thread void add_to(int counter) { counter 1; } int main() { int counter 0; // ❌ 编译失败decay 拷贝出的 int 是右值绑不到 int // std::thread t1(add_to, counter); // ❌ 虽然能编译但线程改的是内部副本外部 counter 永远不变 // std::thread t2([](const int c) { std::cout c; }, counter); // ✅ 要共享就用 std::ref 明确表达 std::thread t3(add_to, std::ref(counter)); t3.join(); std::cout counter \n; // 1 return 0; }回到 socket 场景workers.emplace_back(handle_client, conn)之所以正确恰恰因为这里要的就是拷贝一份 fd 编号——两个线程各持同一编号内核里对应同一个打开文件描述。用std::ref时则必须回答生命周期判据只有一条被std::ref引用的对象能不能保证活到join()返回答不上来就别用引用详见下文坑 2。detach不是解药std::thread析构时会检查joinable()为真就直接std::terminate()——程序当场死掉异常也捕获不到所以必须在join()和detach()之间选一个。但detach()不解决生命周期问题它只是丢掉了必须有人等的义务分离出去的线程若还在访问主线程已销毁的对象就是 UB。所以本文老实把线程放进容器里退出前逐个join()。线程数量与绑核可选accept循环里无限emplace_back意味着线程数随连接数线性增长创建失败时std::thread会抛std::system_error就算不抛调度开销也远超收益。生产环境应当用线程池std::thread::hardware_concurrency()能给出硬件并发度的提示值它是unsigned允许返回 0。想把工作线程固定到特定 CPU 上减少上下文迁移、改善缓存局部性可以用// 仅 Linux/glibc 可用需要 -pthread_GNU_SOURCE 在 g 下默认已定义 #include pthread.h // pthread_setaffinity_np #include sched.h // cpu_set_t / CPU_ZERO / CPU_SET void pin_to_cpu(std::thread t, int cpu) { cpu_set_t set; CPU_ZERO(set); CPU_SET(cpu, set); // cpu 是逻辑 CPU 编号从 0 开始 // libstdc 在 Linux 上 native_handle() 返回 pthread_t实现定义 const int rc ::pthread_setaffinity_np(t.native_handle(), sizeof(set), set); // pthread_* 返回的是错误码本身不是 -1也不设置 errno if (rc ! 0) std::cerr setaffinity failed, rc rc \n; }三点提醒pthread_setaffinity_np是GNU 扩展只在 Linux/glibc及部分 BSD上有Windows 和 macOS 没有等价物它要在线程启动之后调用而那时线程可能已经在跑了想从第一条指令就绑好得在可调用对象内部先绑再干活绑核是否真有收益取决于负载与机器拓扑请自己实测。三、编译与验证服务端做的是原样回显用任何 TCP 客户端都能验证不必专门写一个。下面用 bash 内建的/dev/tcp连上去Linux 的 bash 自带这个重定向不需要装ncg -stdc17 -Wall -Wextra -pthread server.cpp -o server ./server # 建立连接fd 3 既读又写 exec 3/dev/tcp/127.0.0.1/9000 echo hello socket 3 head -n 1 3 # 服务端原样回显应打印 hello socket exec 3- # 关掉写方向服务端会打印对端关闭连接再开一个终端重复上面三行就能看到服务端同时服务两个连接。最后在服务端按CtrlC应看到连接逐个打印对端关闭连接然后server exit正常退出——说明join全都走完了没有detach出去的线程。常见坑点坑 1传引用时忘了std::ref。❌std::thread t(add_to, counter);编译失败——decay 拷贝出的int是右值绑不到int✅ 改用std::thread t(add_to, std::ref(counter));才真正改到外部变量。坑 2std::ref指向的对象提前销毁。❌ 把局部变量包成std::ref交给线程后函数立刻返回栈对象被销毁线程再访问它就是悬垂引用UB✅ 让线程持有数据的所有权按值传递或std::move或把对象生命周期提升到超出join()。坑 3主线程里close(conn)而工作线程还在用它。这会造成双重关闭fd 编号被立刻回收重用第二次close可能关掉一个无关的新套接字症状是随机某个连接断开。❌ 把 fd 拷贝给线程后主线程又::close(conn)✅ fd 所有权明确交给工作线程由线程内的UniqueFd关闭。坑 4忘记忽略SIGPIPE。对端已关闭连接时再send内核给进程发SIGPIPE默认动作是终止进程一个客户端异常断开就能带走整个服务器。// ❌ 什么都不做对端发 RST 之后一次 send 就带走整个进程 // ✅ 忽略它Linux 上还可以给 send 传 MSG_NOSIGNAL 双保险 std::signal(SIGPIPE, SIG_IGN);MSG_NOSIGNAL是Linux 专有标志macOS / BSD 要用SO_NOSIGPIPE所以忽略 SIGPIPE可移植性更好。坑 5recv返回 0 被当成没数据。0 表示对端已关闭发送方向。❌ 把 0 当成读到空数据继续recv只会得到忙等✅ 收到 0 就跳出循环n 0才是出错要按errno处理EINTR重试。坑 6以为一次recv就是一条消息。TCP 是字节流协议没有消息边界发三次send的内容可能被一次recv全收下一次send的 1000 字节也可能分两次才收全。本文的回显程序只负责搬运字节不受影响一旦要处理一行一条命令这类语义必须在应用层做长度前缀或按分隔符缓冲拆包。坑 7accept的addrlen不初始化或对EINTR不处理。sockaddr_in peer {}; socklen_t peer_len sizeof(peer); // ✅ 每次 accept 前都要重新赋值否则 EINVAL const int conn ::accept(listen_fd.get(), reinterpret_castsockaddr*(peer), peer_len); if (conn 0) { if (errno EINTR) continue; // ✅ 被信号打断要重试 std::perror(accept); break; }总结主题要点服务端模型主线程accept一线程一连接fd 所有权按值移交工作线程std::thread传参decay 拷贝、以右值传入引用必须包std::ref/std::crefstd::ref的前提被引用对象必须活过join()否则悬垂引用UB生命周期析构前必须join()或detach()否则std::terminate优先joinSIGPIPE必须忽略否则对端异常断开会让整个进程退出recv返回值0 对端关闭-1 出错看errnoEINTR重试TCP 语义字节流无消息边界一次recv 一条消息是错的线程数量无上限造线程不可行用线程池hardware_concurrency()可能返回 0绑核pthread_setaffinity_np仅 Linux/glibcpthread_*用返回值报错跑通这个例子你就得到了一条完整的接受连接 → 独立线程处理 → RAII 回收 fd → 统一join退出链路。要升级到生产级别要动的不是 socket 调用而是三件事一线程一连接换成线程池、字节流按协议拆包、fd 生命周期收进可安全注销的登记表。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑