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.hpp(IP 地址封装类)
2.2 TcpServer.hpp(TCP 服务器类)
2.3 TcpServer.cc(服务端主程序)
2.4 TcpClient.cc(客户端主程序)
2.5 Makefile
2.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);三个参数,逐个拆:
- sockfd:socket()返回的那个套接字文件描述符,告诉函数要绑哪个套接字。
- 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常用参数,逐个认一下:
-t(tcp):只看TCP相关的套接字。
-u(udp):只看UDP相关的套接字。
-n(numeric):直接显示数字形式的IP和端口,不做域名和服务名解析。速度快,看着也清爽。
-l(listening):只揪出处于LISTEN状态的套接字。想看谁在守店,加它就对了。
-p(programs):显示进程名称,顺带把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.hpp(IP 地址封装类)
#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.hpp(TCP 服务器类)
#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::function<std::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_cast<uint16_t>(std::stoi(argv[1])); } std::unique_ptr<NetModule::TcpServer> server = std::make_unique<NetModule::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_cast<uint16_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 $@ $< -std=c++17 tcp_client: TcpClient.cc g++ -o $@ $< -std=c++17 .PHONY: clean clean: rm -f tcp_server tcp_client2.6 运行方式
编译:
make
先启动服务端(默认端口8080):
./tcp_server
再启动客户端,指定服务端IP和端口:
./tcp_client 127.0.0.1 8080
客户端输入内容,服务端会收到并打印,同时把字符串转成大写返回给客户端。输入quit或exit退出客户端。
如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连支持。你的每一次反馈,都是我继续硬核输出的最大动力。我们下篇见。