:TCP套接字编程实战——从listen监听到客户端与服务器通信)
Hello大家好上一期我们把UDP套接字编程的细节拆了个遍。这一篇是 Linux 网络编程系列的第六篇主角换成面向连接的TCP重点拆解TCP套接字编程的基础流程与核心API。文章会先从socket()创建套接字、bind()绑定地址讲起把参数含义和初始化规范一条条说清楚再拿UDP做参照重点聊TCP独有的listen()、监听状态LISTEN以及怎么用 netstat 这类工具查看连接状态最后用一个“鱼庄拉客”的生活类比分把accept()的工作原理掰开揉碎讲明白“监听套接字”和“服务套接字”到底怎么分工顺便看看单进程TCP服务器的瓶颈卡在哪里。好的我们开始吧。目录一、TCP套接字API详解从创建套接字到建立连接1.1 创建套接字socket函数1.2 绑定本地地址bind函数1.3 开启监听listen函数与TCP监听状态1.3.1 listen函数详解参数与监听队列1.3.2 查看网络进程的监听状态1.3.3 TCP连接建立过程与状态转换1.4 接受连接accept函数与套接字的分工1.4.1 从“好再来鱼庄”的故事理解监听与接待1.4.2 accept函数详解获取已建立的连接1.4.3 连接建立后的数据读取与写入1.5 客户端如何连接服务器connect函数1.5.1 connect函数的基本用法1.5.2 connect函数的使用示例1.5.3 connect函数背后的TCP连接建立过程1.5.4 connect、listen与accept之间的关系1.5.5 客户端需要提前调用bind吗二、完整源码与运行测试2.1 InetAddr.hppIP 地址封装类2.2 TcpServer.hppTCP 服务器类2.3 TcpServer.cc服务端主程序2.4 TcpClient.cc客户端主程序2.5 Makefile2.6 运行方式一、TCP套接字API详解从创建套接字到建立连接搞TCP网络编程第一件事得把Linux提供的标准套接字接口摸熟。这些接口都定义在sys/socket.h头文件里。跟UDP那边一样TCP通信的第一步也是先创建一个套接字把通信端点立起来。1.1 创建套接字socket函数socket()函数干的事就是打开一个网络通信端口。调用成功的话它跟open()系统调用一个套路返回一个套接字文件描述符Socket Descriptor。后面所有的网络读写操作都围着这个描述符转。函数声明#include sys/types.h #include sys/socket.h int socket(int domain, int type, int protocol);参数与返回值跟UDP基本一个模子。唯一的区别在type指定数据传输的服务类型。TCP这边type必须写成SOCK_STREAM表示面向连接的、可靠的字节流传输协议。1.2 绑定本地地址bind函数套接字创建出来了还只是个空壳子没跟任何地址挂上钩。bind()的活就是把这个套接字跟本地的IP地址和端口号绑在一起。函数声明#include sys/types.h #include sys/socket.h int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);三个参数逐个拆sockfdsocket()返回的那个套接字文件描述符告诉函数要绑哪个套接字。addr指向本地地址结构体的指针。实际填的是struct sockaddr_in传进去的时候强转成通用的struct sockaddr*。addrlen地址结构体的大小传sizeof(struct sockaddr_in) 就行。返回值成功返回 0失败返回 -1并设置 errno。1.3 开启监听listen函数与TCP监听状态UDP是无连接的发出去就不管了压根没有“等谁来连”这回事。TCP不一样它是面向连接的连接没建立起来数据就没法传。打个餐馆的比方。你去吃饭老板一定守在店里随时等着客人上门。客人没来他也守着客人来了一拨又一拨他继续守着。这种“一直等在门口”的姿态就是监听。邮件是另一回事。你发一封邮件收件人不需要坐在电脑前一直等邮件先躺在服务器上他什么时候登录什么时候取。发件人和收件人根本不用同时在线。这就是面向数据报的路子。TCP走的是餐馆模式服务端得先摆好架势进入监听状态等着客户端来连。连接没来它就守着连接来了它才起身接客。所以listen()这个函数UDP那边压根用不上TCP这边却绕不开。1.3.1 listen函数详解参数与监听队列listen()干的事一句话让sockfd进入监听状态摆好架势等着客户端上门。门口不可能只来一个人。多个客户端同时发起连接请求这些请求就得排队等着处理。listen()的第二个参数管的就是这条队最多能排多长。函数签名#include sys/types.h #include sys/socket.h int listen(int sockfd, int backlog);参数与返回值逐个拆sockfd已经用bind()绑好地址和端口号的套接字文件描述符。地址都没绑监听个啥所以bind一定得在listen前面。backlog提示队列的最大排队连接数。说白了就是“门口最多允许多少人排队等”。最多允许backlog个客户端处于连接等待状态超出的连接请求系统直接忽略连排队的机会都不给。这个值通常不会设得太大5、128都是常见的选择。设太大队列冗长处理不过来反而添乱设太小又容易把正常请求挡在门外。返回值成功返回0失败返回-1。1.3.2 查看网络进程的监听状态只要TCP服务器成功调用了listen()进入LISTEN状态它就已经具备了接受客户端连接的能力门开了就等人来。想看系统里哪些套接字正在监听Linux命令行工具就能搞定# 查看所有 TCP 和 UDP 监听及连接状态显示 PID 及程序名 netstat -tnap # 仅查看处于 LISTEN 状态的套接字 netstat -tnlp常用参数逐个认一下-ttcp只看TCP相关的套接字。-uudp只看UDP相关的套接字。-nnumeric直接显示数字形式的IP和端口不做域名和服务名解析。速度快看着也清爽。-llistening只揪出处于LISTEN状态的套接字。想看谁在守店加它就对了。-pprograms显示进程名称顺带把PID也亮出来。谁在监听一查便知。1.3.3 TCP连接建立过程与状态转换拿telnet工具向服务器发起连接整个状态变化过程一目了然。未连接之前服务器进程就守在LISTEN状态安安静静等客上门Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 1080682/./tcpserver客户端执行telnet 127.0.0.1 8080建立连接之后网络状态里立刻多出对应的 ESTABLISHED 记录表示连接已经成功建立Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 1080682/./tcpserver tcp 0 0 127.0.0.1:58752 127.0.0.1:8080 ESTABLISHED 1080746/telnet tcp 0 0 127.0.0.1:8080 127.0.0.1:58752 ESTABLISHED -仔细看同一台主机上测试的时候netstat输出里冒出了两条ESTABLISHED记录。为什么第一TCP是全双工通信。通信双方既是发送方也是接收方两条方向相反的数据通道各自独立存在。一条连接两头各有一条链路系统两边都得记着。第二同机测试。客户端和服务器部署在同一台机器上系统在回环网卡127.0.0.1上同时记录了“客户端端口 → 服务器端口”和“服务器端口 → 客户端端口”这两条对称链路。所以看起来像是两条连接实际上是同一条TCP连接的两端各自记了一笔。1.4 接受连接accept函数与套接字的分工学TCP套接字最容易让人犯迷糊的莫过于accept()的返回值为什么调用它之后系统又甩给你一个全新的套接字文件描述符原来创建的那个套接字又跑哪儿去了1.4.1 从“好再来鱼庄”的故事理解监听与接待假设西湖边开着一家极其火爆的“好再来鱼庄”。拉客员张三站在鱼庄门口、马路边专门负责热情招呼路过的游客。他的工作非常纯粹只要看到有顾客想用餐就把人引导进店交给店内的服务员。点菜、端茶、倒水一概不归他管。引导完一位客人他立刻回到门口继续拉客。服务员李四、王二麻坐在店内专门对接已经被张三带进来的顾客。点菜、上菜、陪聊、结账具体的服务工作全由他们负责。这个故事里两个角色对应得清清楚楚监听套接字listensockfd就是拉客员张三。它唯一的使命是在特定端口上监听外部请求负责建立并“获取”连接不承担后续与客户端的数据通信I/O。拉完客它扭头就走回门口继续守着。服务套接字accept()返回的sockfd就是服务员李四。连接建立之后系统专门分配这个套接字来跟该具体客户端进行read/write数据交互。接待、服务、收尾全是它的活儿。1.4.2 accept函数详解获取已建立的连接这里有个认知必须先摆正TCP三次握手建立连接的过程完全由操作系统内核自动完成跟有没有调用accept()没有半点关系。连接在accept()之前就已经建好了安安静静躺在内核的已完成连接队列里。accept()的本质作用是从这个队列里取走一个已经建立好的连接。函数签名#include sys/types.h #include sys/socket.h int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);参数与返回值逐个拆sockfd监听套接字描述符也就是listensockfd对应拉客员张三。它专门传进accept()用于获取连接。addr输出型参数用来获取连接成功的客户端网络地址信息包含客户端的IP和端口号。谁来了一查便知。addrlen值-结果参数。传进去时是addr结构体的长度函数返回时变成实际写入的地址结构体大小。返回值成功时返回一个全新的套接字文件描述符服务套接字对应店里的服务员。获取失败返回-1。后续针对该客户端的所有数据传输read/write或者recv/send统统使用accept()返回的这个新套接字来操作。拉客员只负责把人带进来端茶倒水、点菜结账是服务员的活儿。1.4.3 连接建立后的数据读取与写入收发数据这一块TCP跟UDP走的是两条路。UDP面向数据报有自己专门的收发接口recvfrom/sendto每个数据包独立处理边界清清楚楚。TCP面向字节流没有“包”的概念数据就是一条连续的水流。所以它不需要什么专用接口直接用标准的Linux文件I/O系统调用read()和write()就行。套接字在Linux里本来就是个文件描述符读写它跟读写普通文件是一个道理。水流进来水流出去字节连着字节边界得由应用层自己去划。1.5 客户端如何连接服务器connect函数前面我们已经介绍了TCP服务器端的基本流程通过socket()创建套接字使用bind()绑定本地地址再通过listen()进入监听状态最后调用accept()获取客户端连接。那么问题来了服务器已经准备好了客户端应该如何主动发起连接呢答案就是connect() 函数。connect()是TCP客户端编程中的核心接口它的主要作用是主动向指定的服务器地址发起连接请求。当连接建立成功后客户端就可以通过这个套接字进行数据的发送与接收。1.5.1 connect函数的基本用法首先来看connect()的函数原型#include sys/socket.h int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);这个函数有三个参数sockfd 由 socket() 创建的套接字文件描述符表示客户端准备使用哪个套接字建立连接。addr 指向服务器地址结构体的指针用于指定要连接的目标IP地址和端口号。addrlen 地址结构体的长度通常通过sizeof()获取。返回值也比较简单返回0 连接建立成功。返回-1 连接失败同时设置errno可以通过perror()等方式查看错误信息。1.5.2 connect函数的使用示例在调用connect()之前我们需要先创建TCP套接字并准备好服务器的地址信息。例如假设服务器监听的IP地址为127.0.0.1端口号为8080客户端可以这样编写#include arpa/inet.h #include sys/socket.h #include netinet/in.h #include unistd.h #include cstdio int main() { // 1. 创建 TCP 套接字 int sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd -1) { perror(socket); return 1; } // 2. 填充服务器地址 struct sockaddr_in server_addr{}; server_addr.sin_family AF_INET; server_addr.sin_port htons(8080); if (inet_pton(AF_INET, 127.0.0.1, server_addr.sin_addr) ! 1) { fprintf(stderr, IP地址格式无效\n); close(sockfd); return 1; } // 3. 主动发起连接 if (connect(sockfd, (struct sockaddr*)server_addr, sizeof(server_addr)) -1) { perror(connect); close(sockfd); return 1; } printf(连接服务器成功\n); // 4. 后续可以通过sockfd进行数据收发 // send(sockfd, ...); // recv(sockfd, ...); close(sockfd); return 0; }这段代码的执行过程可以分成四步调用socket()创建一个TCP套接字。填写服务器的IP地址与端口号并完成必要的网络字节序转换。调用connect()主动请求与服务器建立连接。连接成功后使用同一个sockfd进行后续数据收发。1.5.3 connect函数背后的TCP连接建立过程调用connect()并不意味着客户端只是简单地记录一个服务器地址。对于TCP而言它会触发真正的连接建立过程。在正常的TCP三次握手过程中通信双方需要完成以下步骤第一次握手客户端向服务器发送SYN请求建立连接。第二次握手服务器收到请求后返回SYN ACK表示确认客户端的请求同时也请求建立连接。第三次握手客户端收到响应后再发送ACK双方完成连接建立。当连接成功建立后客户端的connect()返回0后续便可以通过该套接字进行TCP数据通信。此时服务端的accept()负责从已完成连接的队列中取出连接并返回一个新的套接字文件描述符供服务端与该客户端进行通信。这里有一个容易混淆的细节客户端的connect()成功返回并不意味着服务端应用程序已经执行了accept()。TCP握手可以先由内核完成已建立的连接随后等待服务端程序调用 accept() 取出。1.5.4 connect、listen与accept之间的关系将客户端和服务端的流程放在一起就更容易理解这三个接口的分工。接口所在一侧核心作用connect()客户端主动发起连接请求listen()服务端将TCP套接字置于监听状态准备接收连接accept()服务端获取一个已建立的连接并返回用于通信的新套接字可以将这个过程概括为客户端调用connect()发起连接服务端通过listen()等待连接并调用accept()获取连接。还要注意服务端的监听套接字与accept()返回的已连接套接字并不是同一个套接字。监听套接字继续负责接收后续连接而新返回的套接字负责与某个客户端进行数据通信。因此服务器可以在保留监听能力的同时与已经连接的客户端交换数据。1.5.5 客户端需要提前调用bind吗前面介绍服务器端时我们专门讲过bind()函数。那么客户端调用connect()之前是否也必须通过bind()指定自己的本地IP地址和端口号呢通常不需要。如果客户端没有显式调用bind()操作系统一般会在连接过程中自动选择合适的本地IP地址和一个可用的临时端口号。客户端只需要指定目标服务器的地址就可以发起连接。当然如果业务确实要求客户端使用指定的本地地址或端口也可以提前调用bind()。这样就能理解服务器通常需要显式绑定固定端口以便客户端能够按照约定访问而普通客户端一般不需要固定本地端口因为操作系统可以自动分配。最后还需要区分阻塞与非阻塞模式对于默认的阻塞套接字connect()通常会等待连接建立成功或失败对于非阻塞套接字如果连接暂时无法立即完成函数通常会返回-1并将errno设置为 EINPROGRESS此时需要通过poll()、select()等机制继续判断连接结果。二、完整源码与运行测试下面把TCP服务器与客户端的完整代码整理出来包括IP地址封装类、TCP服务器类、服务端主程序、客户端主程序以及Makefile。代码可直接编译运行。2.1 InetAddr.hppIP 地址封装类#pragma once #include arpa/inet.h #include netinet/in.h #include sys/socket.h #include string #include cstring namespace NetModule { class InetAddr { public: // 从 sockaddr_in 结构提取 IP 和端口服务端获取客户端地址时使用 InetAddr(const struct sockaddr_in addr) : _addr(addr) { _port ntohs(_addr.sin_port); char ipbuffer[64]; inet_ntop(AF_INET, _addr.sin_addr, ipbuffer, sizeof(ipbuffer)); _ip ipbuffer; } // 从 IP 和 Port 构建 sockaddr_in 结构客户端指定服务端地址时使用 InetAddr(const std::string ip, uint16_t port) : _ip(ip), _port(port) { memset(_addr, 0, sizeof(_addr)); _addr.sin_family AF_INET; _addr.sin_port htons(_port); inet_pton(AF_INET, _ip.c_str(), _addr.sin_addr); } std::string Ip() const { return _ip; } uint16_t Port() const { return _port; } struct sockaddr_in GetAddr() const { return _addr; } ~InetAddr() {} private: struct sockaddr_in _addr; std::string _ip; uint16_t _port; }; }2.2 TcpServer.hppTCP 服务器类#pragma once #include iostream #include string #include functional #include cstring #include cstdlib #include unistd.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/wait.h #include signal.h #include InetAddr.hpp namespace NetModule { // 业务处理函数类型输入请求字符串返回响应字符串 using func_t std::functionstd::string(const std::string ); const uint16_t DEFAULT_PORT 8080; const int BACKLOG 5; class TcpServer { public: TcpServer(uint16_t port DEFAULT_PORT, func_t func nullptr) : _port(port), _func(func), _isrunning(false), _listensockfd(-1) { } // 初始化创建套接字 绑定地址 进入监听状态 void Init() { // 1. 创建 TCP 套接字 _listensockfd socket(AF_INET, SOCK_STREAM, 0); if (_listensockfd 0) { std::cerr socket error std::endl; exit(1); } std::cout socket create success, listensockfd: _listensockfd std::endl; // 2. 设置端口复用避免服务器重启时 bind 失败 int opt 1; setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); // 3. 填充本地地址结构体 struct sockaddr_in local; memset(local, 0, sizeof(local)); local.sin_family AF_INET; local.sin_port htons(_port); local.sin_addr.s_addr INADDR_ANY; // 4. 绑定地址 int n bind(_listensockfd, (struct sockaddr *)local, sizeof(local)); if (n 0) { std::cerr bind error std::endl; exit(2); } std::cout bind success, port: _port std::endl; // 5. 进入监听状态 n listen(_listensockfd, BACKLOG); if (n 0) { std::cerr listen error std::endl; exit(3); } std::cout listen success std::endl; } // 启动服务器循环 accept 读写 void Start() { _isrunning true; // 忽略 SIGCHLD避免子进程变成僵尸 signal(SIGCHLD, SIG_IGN); while (_isrunning) { struct sockaddr_in peer; socklen_t len sizeof(peer); // 1. 获取一个已建立好的连接 int sockfd accept(_listensockfd, (struct sockaddr *)peer, len); if (sockfd 0) { std::cerr accept error std::endl; continue; } InetAddr peerAddr(peer); std::cout [ peerAddr.Ip() : peerAddr.Port() ]# 连接建立, 服务套接字: sockfd std::endl; // 2. 创建子进程处理该客户端 pid_t id fork(); if (id 0) { close(_listensockfd); // 子进程不需要监听套接字 Service(sockfd, peerAddr); close(sockfd); exit(0); } close(sockfd); // 父进程不需要服务套接字 } } ~TcpServer() { if (_listensockfd 0) { close(_listensockfd); } } private: // 与单个客户端通信 void Service(int sockfd, const InetAddr peerAddr) { char buffer[1024]; while (true) { ssize_t s read(sockfd, buffer, sizeof(buffer) - 1); if (s 0) { buffer[s] 0; std::string request buffer; std::cout [ peerAddr.Ip() : peerAddr.Port() ]# request std::endl; std::string response; if (_func) { response _func(request); } else { response Server received: request; } write(sockfd, response.c_str(), response.size()); } else if (s 0) { std::cout [ peerAddr.Ip() : peerAddr.Port() ]# 客户端断开连接 std::endl; break; } else { std::cerr read error std::endl; break; } } } int _listensockfd; uint16_t _port; bool _isrunning; func_t _func; }; }2.3 TcpServer.cc服务端主程序#include TcpServer.hpp #include memory // 业务处理回调把收到的字符串转成大写再返回 std::string Handler(const std::string request) { std::string response request; for (auto ch : response) { if (ch a ch z) { ch - 32; } } return response; } // 用法./tcp_server [端口号] int main(int argc, char *argv[]) { uint16_t port NetModule::DEFAULT_PORT; if (argc 2) { port static_castuint16_t(std::stoi(argv[1])); } std::unique_ptrNetModule::TcpServer server std::make_uniqueNetModule::TcpServer(port, Handler); server-Init(); server-Start(); return 0; }2.4 TcpClient.cc客户端主程序#include iostream #include string #include cstring #include cstdlib #include unistd.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h // 用法./tcp_client [服务端IP] [服务端端口] int main(int argc, char *argv[]) { if (argc ! 3) { std::cerr Usage: argv[0] server_ip server_port std::endl; return 1; } std::string server_ip argv[1]; uint16_t server_port static_castuint16_t(std::stoi(argv[2])); // 1. 创建 TCP 套接字 int sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) { std::cerr socket error std::endl; return 2; } // 2. 填充服务端地址结构体 struct sockaddr_in server; memset(server, 0, sizeof(server)); server.sin_family AF_INET; server.sin_port htons(server_port); inet_pton(AF_INET, server_ip.c_str(), server.sin_addr); // 3. 向服务端发起连接客户端不需要 bind int n connect(sockfd, (struct sockaddr *)server, sizeof(server)); if (n 0) { std::cerr connect error std::endl; close(sockfd); return 3; } std::cout connect success std::endl; // 4. 循环发送 接收 while (true) { std::string line; std::cout Please Enter# ; std::getline(std::cin, line); if (line quit || line exit) { break; } write(sockfd, line.c_str(), line.size()); char buffer[1024]; ssize_t s read(sockfd, buffer, sizeof(buffer) - 1); if (s 0) { buffer[s] 0; std::cout Server Response# buffer std::endl; } else if (s 0) { std::cout 服务端已关闭连接 std::endl; break; } else { std::cerr read error std::endl; break; } } close(sockfd); return 0; }2.5 Makefile.PHONY: all all: tcp_server tcp_client tcp_server: TcpServer.cc TcpServer.hpp InetAddr.hpp g -o $ $ -stdc17 tcp_client: TcpClient.cc g -o $ $ -stdc17 .PHONY: clean clean: rm -f tcp_server tcp_client2.6 运行方式编译make先启动服务端默认端口8080./tcp_server再启动客户端指定服务端IP和端口./tcp_client 127.0.0.1 8080客户端输入内容服务端会收到并打印同时把字符串转成大写返回给客户端。输入quit或exit退出客户端。如果这篇文章对你有帮助别忘了点赞、收藏、关注三连支持。你的每一次反馈都是我继续硬核输出的最大动力。我们下篇见。