网络编程—Socket编程
前言在计算机网络体系中Socket编程是连接应用层与传输层的桥梁。无论是Web服务器、数据库还是即时通讯软件底层都离不开Socket的支撑。本文将从TCP协议基础出发系统讲解Socket API的使用并通过四个版本的Echo服务器演进带你理解单进程、多进程、多线程和线程池四种并发模型的设计思路与实现细节。作为计算机专业的学生掌握Socket编程不仅是课程要求更是理解网络通信原理、构建分布式系统的基础。读完本文你将能够独立编写一个支持多客户端连接的TCP服务器并理解不同并发模型的 trade-off。一、TCP协议1.1 TCP与UDP的区别传输层有两大核心协议TCP和UDP。它们各自适用于不同的场景理解二者的区别是学习Socket编程的前提。对比维度TCP传输控制协议UDP用户数据报协议连接方式面向连接三次握手建立连接四次挥手断开无连接直接发送数据可靠性可靠传输有确认、重传、排序机制不可靠尽最大努力交付可能丢包有序性保证数据按序到达不保证顺序传输效率较低握手、确认、重传有开销较高无需额外开销流量控制滑动窗口机制无拥塞控制慢启动、拥塞避免、快重传、快恢复无应用场景文件传输、HTTP/HTTPS、邮件、远程登录视频直播、DNS、游戏、语音通话编程模型字节流无消息边界数据报有消息边界简单来说TCP像打电话——先接通再说话保证对方能听到UDP像寄信——直接投递不保证对方一定收到。1.2 三次握手TCP是面向连接的协议通信前必须通过三次握手建立连接。这个过程就像两个人打电话小明拨打电话SYN喂能听到吗小红接听并回应SYNACK能听到你呢小明确认ACK我也能听到开始说话吧从技术角度看三次握手的核心目的是序列同步双方的初始号并确认对方的收发能力。每个方向都需要确认对方的初始序号ISN这也是为什么需要三次而不是两次的原因——两次握手只能确认一个方向的能力。为什么不是两次如果只有两次握手服务端发出SYNACK后就认为连接建立若该报文丢失客户端会认为连接未建立服务端却一直等待造成资源浪费也无法抵御历史重复连接报文。1.3 四次挥手TCP连接是全双工的两个方向都需要单独关闭因此断开连接需要四次交互俗称四次挥手。四次挥手的过程可以这样理解第一次挥手FIN主动方发送FIN表示我说完了进入FIN_WAIT_1状态第二次挥手ACK被动方回复ACK表示我知道你说完了进入CLOSE_WAIT状态。此时被动方可能还有数据要发送第三次挥手FIN被动方也发完数据了发送FIN表示我也说完了进入LAST_ACK状态第四次挥手ACK主动方回复ACK进入TIME_WAIT状态等待2MSL后彻底关闭TIME_WAIT状态是TCP设计中的一个关键点。主动关闭方需要等待2个最大报文寿命2MSL原因有二一是确保最后一个ACK能到达对端丢失的话对端会重发FIN此时主动方还能重发ACK二是让网络中残留的旧连接报文全部消逝避免影响新建立的连接。二、Socket APISocket编程的核心是一组系统调用它们构成了网络通信的基础。下面我们逐一解析每个API的作用和使用要点。2.1 socket()创建套接字int socket(int domain, int type, int protocol);socket()函数打开一个网络通信端口成功时返回一个文件描述符应用程序可以像读写文件一样用read/write在网络上收发数据。失败则返回-1。参数说明domain协议族IPv4使用AF_INETIPv6使用AF_INET6type套接字类型TCP用SOCK_STREAM面向流UDP用SOCK_DGRAM数据报protocol具体协议通常指定为0让系统自动选择2.2 bind()绑定地址和端口int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);服务器程序需要监听固定的地址和端口这样客户端才能知道去哪里连接。bind()的作用就是将sockfd和指定的地址端口绑定在一起。成功返回0失败返回-1。服务器端通常这样初始化地址结构struct sockaddr_in local; memset(local, 0, sizeof(local)); local.sin_family AF_INET; local.sin_port htons(port); // 端口号转网络字节序 local.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡地址注意字节序转换网络字节序是大端序主机字节序可能是小端序所以需要htons/htonl等函数转换INADDR_ANY表示监听所有网卡的IP地址因为服务器可能有多个网卡这样设置可以在所有IP上监听2.3 listen()设置为监听状态int listen(int sockfd, int backlog);isten()声明sockfd处于监听状态准备接受客户端连接。这是TCP特有的步骤UDP不需要。backlog参数这个参数定义了内核为该socket维护的连接队列的最大长度。在Linux 2.2之后backlog指的是全连接队列的大小也就是已经完成三次握手、等待accept()取走的连接数量。实际上TCP三次握手过程中Linux内核维护两个队列队列类型说明大小控制半连接队列收到SYN、发送SYNACK后尚未完成三次握手的连接tcp_max_syn_backlog内核参数全连接队列已完成三次握手等待应用层accept()取走的连接min(backlog, somaxconn)如果全连接队列满了新的连接请求会被忽略或拒绝。所以高并发服务器需要合理设置backlog参数。2.4 accept()接受连接int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);三次握手完成后服务器调用accept()从全连接队列中取出一个连接。如果队列中没有连接accept()会阻塞等待。accept()的返回值是一个新的文件描述符专门用于和这个客户端通信。原来的监听socket继续监听新的连接。这一点非常重要——监听socket和通信socket是分开的。用饭店的例子来理解监听socket就像门口的迎宾负责迎接客人accept返回的新socket就像服务员专门为这一桌客人服务。迎宾不会因为来了一桌客人就停止工作而是继续迎接下一桌。2.5 connect()发起连接int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);客户端调用connect()向服务器发起连接。参数形式和bind类似区别在于bind绑定的是自己的地址connect连接的是对方的地址。客户端通常不需要显式调用bind()因为内核会自动分配一个随机端口。如果客户端也绑定固定端口同一台机器上就不能启动多个客户端了——端口会冲突。三、完整流程理解了各个API之后我们来看一下完整的客户端-服务器交互流程服务器端的固定套路是socket → bind → listen → accept → 读写 → close客户端的流程是socket → connect → 读写 → close这个流程是TCP编程的基础几乎所有TCP服务器和客户端都遵循这个模式。下面我们通过代码来具体实现。四、版本演进理解了基础API之后我们来看一个Echo服务器的四个版本演进。Echo服务器的功能很简单客户端发什么服务器就原样返回什么。虽然功能简单但它能很好地展示不同并发模型的设计思路。4.1 V1单进程版本最简单的版本就是单进程循环处理。服务器接受一个连接处理完这个客户端的所有请求再接受下一个连接。1核心代码class TcpServer : public nocopy { public: TcpServer(uint16_t port) : _port(port), _isrunning(false) {} void Init() { // 1. 创建socket _listensock socket(AF_INET, SOCK_STREAM, 0); if (_listensock 0) { LogMessage(Fatal, create socket error); exit(Fatal); } // 设置地址复用避免TIME_WAIT导致重启失败 int opt 1; setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, opt, sizeof(opt)); // 2. 绑定地址 struct sockaddr_in local; memset(local, 0, sizeof(local)); local.sin_family AF_INET; local.sin_port htons(_port); local.sin_addr.s_addr htonl(INADDR_ANY); if (bind(_listensock, CONV(local), sizeof(local)) ! 0) { LogMessage(Fatal, bind socket error); exit(Bind_Err); } // 3. 设置为监听状态 if (listen(_listensock, default_backlog) ! 0) { LogMessage(Fatal, listen socket error); exit(Listen_Err); } } void Service(int sockfd) { char buffer[1024]; while (true) { ssize_t n read(sockfd, buffer, sizeof(buffer) - 1); if (n 0) { buffer[n] 0; std::cout client say# buffer std::endl; std::string echo_string server echo# ; echo_string buffer; write(sockfd, echo_string.c_str(), echo_string.size()); } else if (n 0) { // read返回0表示对端关闭了连接 LogMessage(Info, client quit...\n); break; } else { LogMessage(Error, read socket error); break; } } } void Start() { _isrunning true; while (_isrunning) { // 4. 接受连接 struct sockaddr_in peer; socklen_t len sizeof(peer); int sockfd accept(_listensock, CONV(peer), len); if (sockfd 0) { LogMessage(Warning, accept socket error); continue; } // 5. 提供服务 - V1单进程版本 Service(sockfd); close(sockfd); } } private: uint16_t _port; int _listensock; bool _isrunning; };2客户端代码int main(int argc, char *argv[]) { if (argc ! 3) { Usage(argv[0]); return 1; } std::string serverip argv[1]; uint16_t serverport stoi(argv[2]); // 1. 创建socket int sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) { cerr socket error endl; return 1; } // 2. 发起连接客户端不需要显式bind内核自动分配端口 struct sockaddr_in server; memset(server, 0, sizeof(server)); server.sin_family AF_INET; server.sin_port htons(serverport); inet_pton(AF_INET, serverip.c_str(), server.sin_addr); int n connect(sockfd, CONV(server), sizeof(server)); if (n 0) { cerr connect error endl; return 2; } // 3. 通信 while (true) { string inbuffer; cout Please Enter# ; getline(cin, inbuffer); ssize_t n write(sockfd, inbuffer.c_str(), inbuffer.size()); if (n 0) { char buffer[1024]; ssize_t m read(sockfd, buffer, sizeof(buffer)-1); if (m 0) { buffer[m] 0; cout get a echo message - buffer endl; } else if (m 0 || m 0) { break; } } else { break; } } close(sockfd); return 0; }3问题单进程版本虽然简单但有一个致命问题同一时间只能处理一个客户端。如果第一个客户端连上后不退出第二个客户端就永远连不上——因为服务器一直在Service()的循环里读数据没有机会回到accept()。你可以做个实验启动服务器再启动两个客户端。第一个客户端可以正常通信第二个客户端虽然connect能成功因为三次握手由内核完成但发数据后服务器不会响应——因为服务器还在为第一个客户端服务。这显然不符合实际需求。现实中的服务器需要同时处理成百上千个客户端所以我们需要并发处理能力。4.2 V2多进程版本最容易想到的并发方案就是多进程每来一个客户端就fork一个子进程专门处理它。父进程继续监听新连接。1核心代码void ProcessConnection(int sockfd, struct sockaddr_in peer) { pid_t id fork(); if (id 0) { close(sockfd); return; } else if (id 0) { // 子进程关闭监听socket处理客户端 close(_listensock); // 再fork一次让孙子进程处理子进程立即退出 // 这样孙子进程会成为孤儿进程由init领养自动回收 if (fork() 0) { exit(0); } InetAddr addr(peer); LogMessage(Info, process connection: %s:%d\n, addr.Ip().c_str(), addr.Port()); Service(sockfd); close(sockfd); exit(0); } else { // 父进程关闭通信socket等待子进程退出 close(sockfd); pid_t rid waitpid(id, nullptr, 0); if (rid id) { // 子进程已回收 } } }2解答这段代码里有个巧妙的设计fork两次。原因是僵尸进程问题。如果只fork一次子进程处理完客户端后退出父进程需要waitpid来回收子进程的资源。但父进程在wait的时候就不能accept新连接了这又回到了单进程的问题。fork两次的思路是父进程fork出子进程然后立即waitpid回收子进程很快因为子进程马上就exit子进程fork出孙子进程然后自己立即exit孙子进程变成孤儿进程由系统的init进程领养处理完后自动回收不需要父进程操心这样父进程就能快速回到accept继续接受新连接。3优缺点优点缺点编程简单逻辑清晰进程创建开销大每个进程占用独立内存空间进程间独立一个崩溃不影响其他进程间通信复杂需要IPC机制充分利用多核CPU上下文切换开销大需要切换页表、TLB刷新等稳定性高隔离性好连接数多时进程数太多系统资源消耗大多进程模型适合连接数不多、但每个连接需要大量计算的场景。对于高并发的网络服务动辄上万个连接创建上万个进程是不现实的。4.3 V3多线程版本既然多进程开销大那我们可以用多线程。线程比进程轻量得多创建快、切换快而且共享地址空间通信方便。1核心代码class ThreadData { public: ThreadData(int sockfd, struct sockaddr_in addr) : _sockfd(sockfd), _addr(addr) {} public: int _sockfd; InetAddr _addr; }; class TcpServer : public nocopy { public: // ... Init和之前一样 ... // Service改为静态成员函数因为线程函数必须是静态的 static void Service(ThreadData td) { char buffer[1024]; while (true) { ssize_t n read(td._sockfd, buffer, sizeof(buffer) - 1); if (n 0) { buffer[n] 0; std::cout client say# buffer std::endl; std::string echo_string server echo# ; echo_string buffer; write(td._sockfd, echo_string.c_str(), echo_string.size()); } else if (n 0) { lg.LogMessage(Info, client[%s:%d] quit...\n, td._addr.Ip().c_str(), td._addr.Port()); break; } else { lg.LogMessage(Error, read socket error); break; } } } // 线程执行函数必须是静态的 static void *threadExcute(void *args) { pthread_detach(pthread_self()); // 分离线程自动回收 ThreadData *td static_castThreadData *(args); TcpServer::Service(*td); close(td-_sockfd); delete td; return nullptr; } void ProcessConnection(int sockfd, struct sockaddr_in peer) { InetAddr addr(peer); pthread_t tid; ThreadData *td new ThreadData(sockfd, peer); pthread_create(tid, nullptr, threadExcute, (void*)td); } // ... Start和之前一样 ... };2线程分离代码里调用了pthread_detach这是什么意思呢线程有两种状态可结合的默认状态。线程退出后需要其他线程调用pthread_join来回收资源否则会产生类似僵尸进程的问题分离的线程退出后系统自动回收资源不需要其他线程join我们的服务器不需要等待每个线程结束所以设置为分离状态更方便。3优缺点优点缺点创建开销小比进程轻量得多线程间共享地址空间一个线程崩溃可能导致整个进程退出上下文切换快不需要切换页表需要考虑线程安全问题加锁复杂容易死锁共享内存通信方便不需要IPC调试困难竞态条件难以复现适合I/O密集型任务CPU密集型任务受GIL限制Python等语言4.4 V3-1多线程远程命令执行学会了多线程服务器我们可以做一个更有意思的小项目远程命令执行服务器。客户端发送命令服务器执行后把结果返回给客户端。当然为了安全我们只允许执行少量白名单内的命令比如ls、pwd、whoami等。1Command类class Command { public: Command(int sockfd) : _sockfd(sockfd) { // 白名单只允许执行这些命令 _safe_command.insert(ls); _safe_command.insert(pwd); _safe_command.insert(ls -l); _safe_command.insert(ll); _safe_command.insert(touch); _safe_command.insert(who); _safe_command.insert(whoami); } bool IsSafe(const std::string command) { auto iter _safe_command.find(command); if (iter _safe_command.end()) return false; else return true; } std::string Execute(const std::string command) { if (!IsSafe(command)) return unsafe; // popen执行命令读取输出 FILE *fp popen(command.c_str(), r); if (fp nullptr) return std::string(); char buffer[1024]; std::string result; while (fgets(buffer, sizeof(buffer), fp)) { result buffer; } pclose(fp); return result; } std::string RecvCommand() { char line[1024]; ssize_t n recv(_sockfd, line, sizeof(line) - 1, 0); if (n 0) { line[n] 0; return line; } else { return std::string(); } } void SendCommand(std::string result) { if (result.empty()) result done; send(_sockfd, result.c_str(), result.size(), 0); } private: std::setstd::string _safe_command; int _sockfd; };2Service函数static void Service(ThreadData td) { while (true) { Command command(td._sockfd); std::string commandstr command.RecvCommand(); if (commandstr.empty()) return; std::string result command.Execute(commandstr); command.SendCommand(result); } }这样一个简单的远程命令执行服务器就完成了。客户端连接后输入ls就能看到服务器端的文件列表输入pwd就能看到当前目录。4.5 V4线程池版本多线程版本虽然比多进程轻量但如果连接数非常多创建大量线程也会有问题线程创建和销毁有开销线程太多会导致频繁的上下文切换反而降低性能每个线程都有自己的栈空间占用内存线程池就是为了解决这些问题。我们预先创建一批线程形成一个池子。有任务来了就从池子里取一个线程来处理处理完了线程回到池子里等待下一个任务。1思想线程池主要由三部分组成任务队列存放待处理的任务工作线程一群循环从任务队列取任务执行的线程管理者线程根据任务量动态调整线程数量2代码#include ThreadPool.hpp void Service(int sockfd, InetAddr addr) { char buffer[1024]; while (true) { ssize_t n read(sockfd, buffer, sizeof(buffer) - 1); if (n 0) { buffer[n] 0; std::cout client say# buffer std::endl; std::string echo_string server echo# ; echo_string buffer; write(sockfd, echo_string.c_str(), echo_string.size()); } else if (n 0) { lg.LogMessage(Info, client[%s:%d] quit...\n, addr.Ip().c_str(), addr.Port()); break; } else { lg.LogMessage(Error, read socket error); break; } } } void ProcessConnection(int sockfd, struct sockaddr_in peer) { using func_t std::functionvoid(); InetAddr addr(peer); // 把任务打包成function推送到线程池 func_t func std::bind(TcpServer::Service, this, sockfd, addr); ThreadPoolfunc_t::GetInstance()-Push(func); }3优势对比项每连接一线程线程池线程创建开销每个连接都要创建线程开销大预先创建无额外开销线程数量随连接数增长可能过多固定数量可控上下文切换线程多时切换频繁线程数合理切换少响应速度需要先创建线程有延迟线程已就绪响应快适用场景连接少、长连接连接多、短连接线程池是高并发服务器的常用方案。不过线程池也不是银弹——如果任务都是CPU密集型的线程数设置成CPU核数1就够了如果是I/O密集型的线程数可以多一些但也不是越多越好。六、总结本文从TCP协议基础出发系统讲解了Socket编程的核心API并通过四个版本的Echo服务器演进展示了从单进程到多进程、多线程、线程池的并发模型设计思路。TCP是面向连接的可靠传输协议通过三次握手建立连接四次挥手断开连接保证数据可靠有序地传输Socket API是网络编程的基础核心包括socket、bind、listen、accept、connect等系统调用accept返回新的socket监听socket和通信socket是分开的这是理解TCP服务器的关键单进程服务器只能处理一个连接实际应用中需要并发处理能力多进程模型稳定但开销大适合连接少、对稳定性要求高的场景多线程模型轻量高效但需要处理线程安全问题适合I/O密集型场景线程池通过复用线程减少创建开销适合高并发短连接场景