ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++网络编程入门:从socket到实现基础TCP服务端客户端

C++网络编程入门:从socket到实现基础TCP服务端客户端

最近又有几个学C++的朋友跑来问我:明明照着书敲了TCP代码,为什么客户端一连接就崩?为什么服务端按完Ctrl+C再重启就报端口被占用?为什么对方只发了十个字节,我这边read却读出来二十个?这些问题,我在刚接触网络编程那会儿全都撞过。与其一个个回复,不如把这条最常走的入门路径完整写下来,围绕“C++网络编程学习:建立基础TCP服务端/客户端”这件事,从socket调用到能跑通一个最简单的echo服务,再把Windows和Linux的差异、收发边界、调试心得都讲清楚。文章里给的代码是完整可编译的,适合刚学完C++基础语法、想接触网络编程的人,也适合写过一点socket但总在奇怪问题上卡壳的同学。

1. 先搞懂三件事:服务端模型、阻塞与非阻塞、文件描述符

1.1 服务端到底在“等服务”什么

TCP服务端的工作方式,用电话总机来类比特别直观。你要提供服务,第一步是向交换机申请一条电话线,这条线对应一个socket;第二步是把这条线接到某个对外公布的号码上,也就是bind到IP和端口;第三步是告诉交换机“我已经准备好了,可以接电话了”,对应listen;之后就是坐在那儿等电话进来,对应accept。值得一提的是,accept接过一个电话之后,并不是把这条总机线路交给这个通话,而是另外接出一条新的线路来处理当前通话的人。总机线路继续留在门口等着下一个通话进来,这就是多连接能够共存的根本原因。

理解了这套模型,再去看API,顺序就不会错。新手最常见的错误就是把listen当成了“开始接受数据”,以为listen之后就能直接recv。其实listen只是让socket进入被动监听状态,它不产生连接,不接收数据。真正产生连接的是accept,它会从内核的完成队列里取出一个已经完成握手的连接,然后返回一个全新的、专门用于此连接收发的文件描述符。

1.2 阻塞与非阻塞:一次API调用会挂住整个进程

网络编程里最容易让新手懵掉的是“阻塞”这个词。默认创建的socket是阻塞模式的,意味着当你调用accept时,如果没有新连接进来,进程会停在这一行,不走也不退出;当你调用read时,如果对端没有发数据,进程同样会卡在read这一行。这跟本地文件读写很不一样——读本地文件哪怕再慢,最终也会读到EOF结束,但网络socket的“EOF”要等到对端关闭连接才会出现。

这种阻塞特性是好事也是坏事。好处是程序逻辑简单,一次只处理一个连接,不容易写出数据竞争;坏处是单个连接会占住整个进程,如果客户端连上来之后一直不说话,服务端就被“吊死”在read里,后面的连接全部排队等着。后面我们会看到,这个特性是所有“假死”问题的根源。入门阶段,先用阻塞式通信跑通全流程,等理解了状态转换,再去处理非阻塞、事件驱动这些进阶话题。

1.3 为什么socket返回的是“文件描述符”

在Linux上,socket函数返回的是一个整数,这个整数叫文件描述符。很多C++初学者会对“网络连接为什么是个整数”感到疑惑。可以这么理解:操作系统把网络连接也抽象成了一种“文件”,你读写这个文件就等于往网络上发数据或收取数据。所以read(conn_fd, buf, n)读的是从TCP对端收到的数据,write(conn_fd, buf, n)写的是要发给TCP对端的数据。

这也是为什么网络程序会大量复用文件读写的概念:close关闭连接、select/poll等待描述符可读可写、send/recv只是带了标志位的读写变体。把网络抽象成文件,是Unix几十年来的设计哲学,理解了这一点,你再看accept返回的整数、函数签名里大量出现的int形参,就不会觉得莫名其妙了。

2. 服务端骨架:从socket到accept,每一步都在做什么

2.1 创建socket:AF_INET与SOCK_STREAM的含义

先看一个最朴素的TCP服务端骨架,完整代码后面会逐步拆解:

#include <iostream> #include <cstring> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } std::cout << "socket created, fd = " << listen_fd << std::endl; close(listen_fd); return 0; }

socket有三个参数。第一个AF_INET表示使用IPv4网络,如果你的环境是纯IPv6场景,这里就应该换成AF_INET6,但入门统一用IPv4最稳。第二个参数SOCK_STREAM表示TCP流式协议,它的特点是数据像水管里的水一样没有边界地源源不断流过去,也因此才有了后面要讲的粘包问题。第三个协议参数填0,表示让内核根据前两个参数自动选择TCP协议。

2.2 端口绑定前的三件小事:清零、转字节序、复用地址

有了socket还不够,服务端必须绑定一个固定的端口,客户端才能找到它。绑定的核心是bind函数,但新手往往栽在sockaddr_in的准备上:

int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); sockaddr_in addr; std::memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(9000); if (bind(listen_fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }

三件小事缺一不可。第一件是memset清零。sockaddr_in里面除了已知字段之外还有填充字节,不清零的话这些字节是栈上的随机值,内核在比较地址时可能报“地址已被占用”或者莫名其妙绑定失败。第二件是字节序转换。网络字节序是大端,而绝大多数PC是小端。htonl(INADDR_ANY)把“任意本机IP”转成网络序,htons(9000)把端口号转成网络序。不要自己手写交换函数,直接调用系统接口是最保险的。第三件是SO_REUSEADDR。它告诉内核:如果当前端口上有连接处于TIME_WAIT状态,也允许我重新绑定。没有这一行,你每次Ctrl+C再立即重启服务端,大概率会撞见Address already in use。

额外说一句:INADDR_ANY表示绑定到本机所有网卡地址。如果只想让本机访问,可以改成127.0.0.1;如果想让局域网其他机器也能访问,用INADDR_ANY是最省事的。修改地址时需要用inet_pton(AF_INET, "192.168.1.10", &addr.sin_addr)把字符串转成二进制,不过入门阶段用INADDR_ANY就够了。

2.3 listen监听队列与accept的“分身”机制

绑定端口后,进入监听与接受连接阶段:

if (listen(listen_fd, 16) < 0) { perror("listen"); return 1; } std::cout << "listening on 0.0.0.0:9000" << std::endl; while (true) { sockaddr_in client_addr; socklen_t addr_len = sizeof(client_addr); int conn_fd = accept(listen_fd, (sockaddr*)&client_addr, &addr_len); if (conn_fd < 0) { perror("accept"); continue; } std::cout << "new client connected, fd = " << conn_fd << std::endl; char buf[1024]; while (true) { ssize_t n = read(conn_fd, buf, sizeof(buf)); if (n <= 0) { break; } write(conn_fd, buf, n); } close(conn_fd); }

listen的第二个参数是未完成连接队列和已完成连接队列的总容量上限。这里填16,在并发不高的测试场景完全够用。如果填0,内核仍会给出一个能用的默认值,但按照规范,填0后行为在不同系统上并不一致,所以不要觉得“填0就代表无限”。accept会阻塞在这里等待新连接,一旦有连接完成TCP三次握手,它就从已完成队列里取出一个连接,同时把对端的IP和端口写入到client_addr这个结构体里。

accept返回的conn_fd是“分身”,listen_fd是“大门”。一个进程里可以同时有多个conn_fd,但只有一个listen_fd。正因为这个设计,accept之后对conn_fd的读写完全不影响listen_fd继续接受新连接。上面这个服务端是串行处理连接的:先处理完当前conn_fd的所有数据,把对端发来的数据原样写回去,直到对端关闭连接,read返回0,才跳出内层循环去accept下一个连接。这个模型简单,但并发能力等于零,后面第6章会聊怎么处理。

3. 客户端连接与数据收发:connect的另一面

3.1 connect:发起一条TCP连接的三次握手

客户端这一侧比服务端简单一些,因为它不需要bind、listen、accept。它只需要创建socket,然后调用connect去主动连接服务端。connect做的事,可以理解为向服务端发起一次TCP三次握手的请求:客户端先发送SYN,服务端收到后回复SYN+ACK,客户端再回一个ACK,连接建立完成。这个握手过程由内核自动完成,程序员不需要手动处理这些包。

需要注意,connect是阻塞调用。如果服务端没启动、被防火墙丢弃、或者IP不可达,connect可能等很久才返回超时错误。在Linux上,这个超时时间通常由内核参数和系统配置决定,动辄几秒到几十秒。所以测试时,最好先确保服务端已经起来,再运行客户端。

3.2 send/recv和read/write的异同

很多人看到两种写法:send/recv和read/write,不知道有什么区别。简单说,read/write是通用的文件读写接口,send/recv是socket专用的读写接口。send多了第四个参数flags,平时填0即可,它的返回值语义和write一样:返回成功写入到内核发送缓冲区的字节数,注意是“写入内核缓冲区的数量”,不保证对端已经收到。recv的flags也可以填0,返回值语义和read一样:返回本次实际读到的字节数,返回0表示对端关闭了连接,返回-1表示出错。

入门的代码里,我混用了这两组函数。服务端用read/write,客户端用send/recv,目的是让大家都认识。实际项目里最好统一一下,尤其是要用MSG_WAITALL这类标志位时,必须走recv。但无论如何,都要记住:不可能通过一次read/recv保证读到对方逻辑上的“一条完整消息”。TCP是字节流,不是消息流。

3.3 客户端代码拆解

下面是一个能跑通的客户端完整代码:

#include <iostream> #include <cstring> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> int main() { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { perror("socket"); return 1; } sockaddr_in addr; std::memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(9000); inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); if (connect(fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("connect"); return 1; } const char* msg = "hello, tcp\n"; ssize_t send_len = send(fd, msg, std::strlen(msg), 0); std::cout << "send bytes: " << send_len << std::endl; char buf[1024]; ssize_t n = recv(fd, buf, sizeof(buf), 0); if (n > 0) { std::cout.write(buf, n); } close(fd); return 0; }

客户端的地址结构里,IP填的是127.0.0.1,也就是本机回环地址。这样即使你的电脑没有接入网络,服务端和客户端也都能在同一台机器上跑通。如果要在局域网内连接另一台机器,把127.0.0.1换成那台机器的IP即可。inet_pton把点分十进制的IP字符串转换成网络字节序的二进制形式,比老式的inet_addr更安全,推荐使用。整个客户端就三件事:建socket、connect、收发数据。它不需要监听,也不需要accept,因为连接的建立和接受,都是服务端那边负责的。

4. send/recv并不保证按消息边界交付:半包与粘包

4.1 一个read,到底能读到多少数据

上面的服务端代码虽然能跑,但它隐藏着一个让无数新手崩溃的问题:客户端如果发送了100字节,服务端的一次read未必能读到100字节。为什么会这样?因为TCP是字节流,数据被内核拆分成多个报文段发送,中间还可能经过路由、网络拥塞、发送方缓冲区合并等等。服务端调用read时,只能拿到当前内核接收缓冲区里已经到达的数据,这个数量可能小于你期望的“完整消息”,也可能大于——比如发送方连续发了两条消息,两条消息的数据可能一次性被read读出来。

这就是半包和粘包。收到一半叫半包,两条消息黏在一起叫粘包。它们不是bug,而是TCP字节流特性的必然结果。真正解决它们的方式只有一个:在应用层约定消息边界。也就是说,TCP只保证字节的先后顺序,不保证你对消息的分割。

4.2 简单echo服务里的隐藏缺陷

用前面的服务端去echo,客户端发送“hello”,服务端可能分两次读到“he”和“llo”,然后分别把“he”和“llo”写回去。如果客户端只是简单打印收到的东西,这两次加起来还是“hello”,看起来没问题。但如果客户端发送的是两条独立的消息,比如“left”和“right”,服务端可能一次read就把“leftright”都读出来,然后把“leftright”写回去。接收端就完全分不清这是一条消息还是两条了。

所以,网络编程入门之后要做的第一件事,就是设计一个最简单的消息帧协议。最朴素的做法是“长度字段+内容”:每条消息前4个字节用二进制存放后续内容的长度,接收方先读满4个字节,再按长度继续读。为了处理简单,可以用网络字节序保存长度。服务端读取的循环要改成这样:

uint32_t body_len = 0; recv(conn_fd, &body_len, 4, MSG_WAITALL); body_len = ntohl(body_len); std::vector<char> body(body_len); recv(conn_fd, body.data(), body_len, MSG_WAITALL);

使用带MSG_WAITALL标志的recv,可以阻塞到读满指定长度。但要注意:这个标志在非阻塞socket上不一定能保证读满,而且客户端发送时也要以同样的格式先把长度发出去。这种方法虽然简陋,但能回答最核心的问题:消息到底从哪里结束,下一条消息从哪里开始。

4.3 从“能跑”到“能用”:最朴素的定长包方案

继续顺着上面的设计往下说。如果消息内容本身是文本,可以约定“以换行符结尾作为一条消息”,也就是一行一行地读,读到\n就算一条完整的消息。很多线上协议就是这么做的,比如Redis的协议、HTTP的头部,本质都是“分隔符或长度”的思路。更通用的做法是TLV编码:Type-Length-Value,先约定类型和长度,再读取主体。三种方案按复杂度排序是:定长包、分隔符、长度前缀。入门阶段用长度前缀最直观,也最接近真实项目。

写一个自定义的小工具函数,读取指定长度的数据,并在断开时抛出错误:

bool recv_full(int fd, void* buf, size_t n) { size_t already = 0; char* p = static_cast<char*>(buf); while (already < n) { ssize_t r = recv(fd, p + already, n - already, 0); if (r == 0) return false; if (r < 0) return false; already += r; } return true; }

这段代码是读完整条消息的关键。它解决的是半包问题:一次recv拿不到n个字节,就继续循环,直到凑满为止。你可能会问,这样会不会把下一条消息的数据也“吸”进来?不会,因为最多只读到n个字节,读满了就退出循环,多出来的数据留在内核缓冲区里,等下一次调用再读。这就是应用层分包的基本功。建议把这几个函数写在一个demo_net.h里,后续继续学的时候会反复用到。

5. 编译运行与Windows差异:一次编写,两处修改

5.1 Linux下的编译与联调

在Linux环境,比如Ubuntu或CentOS上,编译只需要一条命令:

g++ -Wall -g demo_server.cpp -o demo_server g++ -Wall -g demo_client.cpp -o demo_client

-g是为了方便调试,-Wall会输出所有警告,新手看到警告千万别忽略。编好之后先启动服务端:

./demo_server

它会打印listening on 0.0.0.0:9000。然后另一个终端运行客户端:

./demo_client

正常情况客户端会打印send bytes: 11,同时把hello, tcp原样打回来。想快速验证服务端,可以不用写客户端,直接用系统里常见的网络测试命令连接:nc 127.0.0.1 9000,输入一行字,看服务端是否原样返回。

5.2 Windows下Winsock的“三处不同”

很多同学在Windows的Visual Studio里编译上面的代码会报错,因为Windows用的是Winsock接口,和Linux的POSIX socket有区别。第一处不同是头文件与库:要包含<winsock2.h>,并在链接参数里加上ws2_32.lib,如果使用MSVC,可以在项目属性里配置,或者用代码里的#pragma comment(lib, "ws2_32.lib")。第二处不同是必须先调用WSAStartup初始化Winsock,否则后面所有socket函数都返回SOCKET_ERROR。第三处不同是socket句柄类型是SOCKET而不是int,关闭使用closesocket,而且send/recv返回int类型。

一个兼容Windows和Linux的最小初始化片段长这样:

#ifdef _WIN32 #include <winsock2.h> #include <ws2tcpip.h> #else #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #endif // 在main最开始调用 #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); #endif // 结束时 #ifdef _WIN32 WSACleanup(); #else close(fd); #endif

把这两段代码放到自己的工具头文件里,Linux和Windows就能共用大部分业务代码。另外要注意,Windows下close不存在,要用closesocket;unistd.h也不存在,不要直接包含。入门时最好先在一套平台上跑通,理解原理之后再移植,不要一开始就追求跨平台。

5.3 再谈一个“看起来没用”的地址结构清零

在第2章提到的memset,在Windows上同样重要。Winsock的sockaddr_in结构在内存布局上不同平台基本一致,但万一你漏了清零,MSVC的Debug模式可能会把未初始化内存填充成0xCD,导致字段判断出错。学习阶段养成初始化任何一个网络结构体都先清零的习惯,能避免一堆无意义的排查。这条经验同样适用于sockaddr_in6、addrinfo等结构体,它们的填充字段更多,更容易出错。

6. 调试中我反复遇到的三个问题

6.1 Address already in use:为什么明明没连接却报端口占用

这是新人遇到过最多次的报错。你跑完服务端,Ctrl+C结束进程,紧接着再启动,bind告诉你端口已经被占用。原因在于TCP连接关闭后,连接会在内核里进入一段名为TIME_WAIT的状态,通常会持续几十秒到几分钟。这个阶段里,端口并没有被“彻底释放”。解决办法就是第2章里那行SO_REUSEADDR选项。setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))必须在bind之前调用。把它放在bind后面调用是无效的,因为地址已经在绑定时被内核检查过了。

这里有个细节:有的人会写出SO_REUSEPORT,这个选项和SO_REUSEADDR完全不是一回事。SO_REUSEPORT用于多个进程复用同一个端口,用于负载均衡,Linux较新内核才支持。入门阶段不要混着用,就用SO_REUSEADDR解决重启问题。

6.2 Broken pipe:向已关闭的连接继续发消息

服务端在并发场景下经常撞见的另一个报错是Broken pipe。它发生在对端已经关闭连接,而当前进程仍然往这个conn_fd上write/send时。内核会发一个SIGPIPE信号给当前进程,如果程序没有处理这个信号,默认行为是终止进程。新手经常莫名其妙地看到服务端进程“挂了”,没有core dump,也没有明显的C++异常,其实是被SIGPIPE干掉的。

处理方式有两种。一种是直接在main里忽略信号:

#include <csignal> signal(SIGPIPE, SIG_IGN);

忽略后,写入已经关闭的socket会返回-1,并置errno为EPIPE。然后你的代码判断到返回值小于0,就知道这个连接已经不可用了,可以安全地关闭这个连接,不影响整个进程。第二种方法是在发送前先检查对端是否关闭,但要确认对端关闭往往需要一次读操作,比较麻烦。所以实战里更常用的是忽略SIGPIPE加返回值判断,这一行代码能让你的服务端稳定很多。

6.3 客户端一多,服务端就“假死”:问题出在串行阻塞

前面那个串行echo服务端,一次只能处理一个连接。比如A连上来,不发数据,不发关闭,只是挂着。这时服务端就阻塞在read(conn_fd),B想连也连不上,accept根本不会执行。这不是网络的错,也不是内核的错,而是程序结构造成的。最直接的改进是用多线程:accept到一个新连接就创建线程去处理它的读写,主线程继续accept。再往后可以学线程池、poll/epoll、事件循环,但这已经超出“基础TCP”的范围了。

不过有一件事值得在入门时就记住:阻塞式socket + 串行循环 = 低并发。如果你只是想搞通机制,它完全合格;如果以后要写真实服务端,应该尽早转入非阻塞+事件驱动模型。但你也不必因为这点焦虑,先把手头的echo跑熟,再去学select和epoll,这个顺序完全合理。

我第一次跑通TCP echo程序时,也有过“怎么这么简单”的错觉,真正开始写更多协议时才发现坑都在字节流边界和阻塞模型上。不过反过来讲,只要把这篇里的几个关键点——地址准备、收发边界、端口复用、SIGPIPE——都踩过一次并且弄懂了,之后再学什么网络库、什么协议栈,都会轻松很多。如果你也在学C++网络编程,代码里卡住的时候,不妨先用最简单的工具连接服务端、把问题拆成“没连上”“连上但没数据”“连上但收不全”三段去排查,很多困惑都能解决得比想象中快。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进