
前言今天继续推进仿 muduo 的 C 高并发服务器项目。昨天主要实现的是Buffer缓冲区模块用来解决 TCP 字节流读写过程中可能出现的半包、粘包、一次发送不完整等问题。Buffer更偏向应用层数据管理而今天实现的Socket类则更靠近网络 IO 的底层。在 Linux 网络编程中如果每个模块都直接调用socket、bind、listen、accept、recv、send这些系统接口代码会比较分散而且错误处理也容易重复。后面继续写Acceptor、TcpServer、TcpConnection的时候如果不先封装好 socket 操作代码会越来越乱。所以今天的目标很明确封装一个Socket类把裸文件描述符和常用 socket 系统调用统一管理起来为后续网络库模块打基础。一、为什么要封装 Socket 类Linux 下的 socket 本质上也是一个文件描述符也就是一个int类型的 fd。服务端创建连接时一般要经历下面几个步骤socket - bind - listen - accept客户端连接服务器时一般是socket - connect连接建立以后通信过程主要依赖recv / send如果直接在业务代码里反复写这些系统调用会有几个问题每次都要手动判断返回值错误处理重复fd 生命周期容易混乱忘记关闭会造成资源泄漏阻塞、非阻塞、地址复用等选项容易分散在各处后续TcpConnection之类的模块会被底层细节污染因此我先封装了一个轻量级的Socket类让它专门负责 socket fd 的创建、绑定、监听、连接、收发和关闭。二、Socket 类的整体设计这次的Socket类内部只保存一个成员private: int _sockfd;构造函数中将_sockfd初始化为-1表示当前还没有管理有效的 socketSocket() : _sockfd(-1) {} Socket(int fd) : _sockfd(fd) {}析构函数里调用Close()~Socket() { Close(); }这里其实体现了 RAII 的思想对象的生命周期和资源的生命周期绑定在一起。Socket对象创建后负责管理 fdSocket对象销毁时自动关闭 fd。这样做的好处是后面在连接对象析构、异常退出或者局部对象离开作用域时不需要每次都手动记得close(fd)可以减少资源泄漏的风险。三、创建 TCP Socket创建套接字的接口是Create()bool Create() { _sockfd socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (_sockfd 0) { ERR_LOG(CREATE SOCKET FAILED!!); return false; } return true; }这里使用的是AF_INET表示 IPv4SOCK_STREAM表示面向字节流的 TCPIPPROTO_TCP表示使用 TCP 协议如果创建失败socket()会返回小于 0 的值这时通过日志输出错误信息并返回false。这一步完成以后_sockfd就保存了一个有效的 socket 文件描述符。四、服务端流程Bind、Listen、Accept服务端 socket 的核心流程是绑定地址、开始监听、接收新连接。1. 绑定地址 BindBind()负责把 socket 和指定的 IP、端口绑定起来bool Bind(const std::string ip, uint16_t port) { struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.s_addr inet_addr(ip.c_str()); socklen_t len sizeof(struct sockaddr_in); int ret bind(_sockfd, (struct sockaddr*)addr, len); if (ret 0) { ERR_LOG(BIND ADDRESS FAILED!); return false; } return true; }这里需要注意两个转换htons(port)主机字节序转网络字节序inet_addr(ip.c_str())点分十进制 IP 字符串转网络字节序整数服务端如果绑定的是0.0.0.0表示监听本机所有网卡地址。2. 开始监听 Listen绑定完成后调用Listen()把 socket 转成监听状态bool Listen(int backlog MAX_LISTEN) { int ret listen(_sockfd, backlog); if (ret 0) { ERR_LOG(SOCKET LISTEN FAILED!); return false; } return true; }这里的backlog经常会在面试中被问到。它不是“服务器最大并发连接数”而是内核中已完成连接队列的长度上限。客户端完成三次握手后连接会进入这个队列等待应用层调用accept()取走。3. 获取新连接 Accept当监听 socket 上有新连接到来时调用Accept()int Accept() { int newfd accept(_sockfd, NULL, NULL); if (newfd 0) { ERR_LOG(SOCKET ACCEPT FAILED!); return -1; } return newfd; }accept()返回的是一个新的 fd。监听 socket 只负责接收新连接而真正和客户端通信的是accept()返回的新连接 fd。后面写Acceptor和TcpConnection时这一点会非常重要。五、客户端流程Connect客户端创建 socket 后需要主动连接服务器bool Connect(const std::string ip, uint16_t port) { struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.s_addr inet_addr(ip.c_str()); socklen_t len sizeof(struct sockaddr_in); int ret connect(_sockfd, (struct sockaddr*)addr, len); if (ret 0) { ERR_LOG(CONNECT SERVER FAILED!); return false; } return true; }这部分和服务端的Bind()很像都是先填充sockaddr_in结构体。区别在于服务端bind()是把自己的 socket 绑定到本地 IP 和端口客户端connect()是连接远端服务器的 IP 和端口六、数据接收 Recv连接建立以后就可以通过Recv()接收数据ssize_t Recv(void *buf, size_t len, int flag 0) { ssize_t ret recv(_sockfd, buf, len, flag); if (ret 0) { if (errno EAGAIN || errno EINTR) { return 0; } ERR_LOG(SOCKET RECV FAILED!!); return -1; } return ret; }这里对recv()的返回值做了统一处理ret 0实际读到的字节数ret 0读取出错ret 0对端关闭连接代码中把EAGAIN和EINTR单独处理为返回0EAGAIN非阻塞情况下当前接收缓冲区没有数据EINTR阻塞等待过程中被信号中断不过这里也有一个后续可以优化的点recv()返回0本身表示对端正常关闭连接而当前封装中EAGAIN、EINTR也返回0这会让“暂时没读到数据”和“连接关闭”不太容易区分。后面接入TcpConnection时可以进一步细化返回状态。七、数据发送 Send发送数据使用Send()ssize_t Send(const void *buf, size_t len, int flag 0) { ssize_t ret send(_sockfd, buf, len, flag); if (ret 0) { if (errno EAGAIN || errno EINTR) { return 0; } ERR_LOG(SOCKET SEND FAILED!!); return -1; } return ret; }这里有一个很重要的点send()一次不一定能把所有数据都发送完。尤其是在非阻塞 IO 场景下如果内核发送缓冲区空间不足send()可能只发送一部分数据甚至返回EAGAIN。所以后面必须配合Buffer保存剩余数据等 socket 下次可写时再继续发送。这也是昨天实现Buffer的意义之一。八、单次非阻塞收发当前类里还封装了两个非阻塞收发接口ssize_t NonBlockRecv(void *buf, size_t len) { return Recv(buf, len, MSG_DONTWAIT); } ssize_t NonBlockSend(void *buf, size_t len) { if (len 0) return 0; return Send(buf, len, MSG_DONTWAIT); }MSG_DONTWAIT表示本次recv()或send()调用使用非阻塞方式。它和fcntl(fd, F_SETFL, O_NONBLOCK)的区别是MSG_DONTWAIT只影响当前这一次系统调用O_NONBLOCK会改变 fd 本身的阻塞属性所以MSG_DONTWAIT更像是“临时非阻塞”而O_NONBLOCK是“这个 fd 以后都按非阻塞方式工作”。九、设置 fd 为非阻塞NonBlock()通过fcntl()修改 fd 属性void NonBlock() { int flag fcntl(_sockfd, F_GETFL, 0); fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK); }高并发服务器通常都会使用非阻塞 IO。原因是事件循环线程不能被某一个连接卡住。比如一个客户端迟迟不发数据如果服务器在这个 fd 上阻塞等待那么整个线程都无法继续处理其他连接。非阻塞 IO 配合epoll这类 IO 多路复用机制才能让一个线程同时管理大量连接。后面实现Channel、Poller、EventLoop时非阻塞 fd 会是整个 Reactor 模型的基础。十、地址和端口复用ReuseAddress()用来设置地址复用void ReuseAddress() { int val 1; setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, (void*)val, sizeof(int)); val 1; setsockopt(_sockfd, SOL_SOCKET, SO_REUSEPORT, (void*)val, sizeof(int)); }这里设置了两个选项SO_REUSEADDRSO_REUSEPORTSO_REUSEADDR常用于解决服务器重启时端口还处于TIME_WAIT等状态导致短时间内无法重新绑定的问题。SO_REUSEPORT允许多个 socket 绑定同一个 IP 和端口常见用途是多进程或多线程模型下让内核做连接分发。不过这里有一个细节地址复用最好在bind()之前设置。因为它影响的是绑定行为如果在Bind()之后才调用实际效果可能不符合预期。当前代码中的CreateServer()是这样写的bool CreateServer(uint16_t port, const std::string ip 0.0.0.0, bool block_flag false) { if (Create() false) return false; if (block_flag) NonBlock(); if (Bind(ip, port) false) return false; if (Listen() false) return false; ReuseAddress(); return true; }后续可以调整为Create - ReuseAddress - NonBlock - Bind - Listen另外block_flag这个名字也可以再优化一下。因为当前传入true时调用的是NonBlock()所以它表达的其实是“是否设置为非阻塞”命名成nonblock或nonblock_flag会更清晰。十一、封装服务端和客户端创建流程为了减少外部使用成本类中还提供了两个组合接口。服务端创建bool CreateServer(uint16_t port, const std::string ip 0.0.0.0, bool block_flag false) { if (Create() false) return false; if (block_flag) NonBlock(); if (Bind(ip, port) false) return false; if (Listen() false) return false; ReuseAddress(); return true; }客户端创建bool CreateClient(uint16_t port, const std::string ip) { if (Create() false) return false; if (Connect(ip, port) false) return false; return true; }这样外部使用时就不需要每次都手动组合系统调用。比如后面Acceptor中只需要创建监听 socketTcpClient中只需要创建客户端 socket不必关心每个系统调用的细节。十二、Socket 和 Buffer 的关系到这里项目中已经有了两个很关键的基础模块Socket负责底层网络 IOBuffer负责应用层数据缓存它们各自解决的问题不一样。Socket关心的是 fd 能不能读、能不能写、连接有没有建立、系统调用有没有失败。Buffer关心的是数据有没有读完整、业务处理到哪里了、剩余数据要不要暂存、发送没发完的数据要不要下次继续发。后面写TcpConnection时每个连接大概率都会持有一个 socket fd 一个输入缓冲区 input buffer 一个输出缓冲区 output buffer读事件到来时从Socket读数据到输入Buffer。写事件到来时从输出Buffer取数据通过Socket继续发送。这样网络 IO 和数据管理就被拆开了代码结构会更清楚。十三、简单测试思路当前可以先用一个简单的回显服务器验证Socket类是否正常。服务端流程1. CreateServer(port) 2. 循环 Accept() 3. 对新连接调用 Recv() 4. 收到数据后调用 Send() 原样发回去客户端流程1. CreateClient(port, ip) 2. Send() 发送字符串 3. Recv() 接收服务端回显重点验证socket 能否成功创建服务端能否成功绑定和监听端口客户端能否成功连接服务端能否成功accept()新连接基础recv()/send()是否正常工作这一步不追求复杂并发只要先验证封装的基础接口可用即可。十四、面试可能提问的点1. 为什么要封装 Socket 类主要是为了降低系统调用的使用成本统一错误处理并通过 RAII 管理 fd 生命周期。如果不封装后面的Acceptor、TcpConnection、TcpServer中会到处散落socket、bind、listen、accept、recv、send等系统调用代码可读性和可维护性都会变差。2. 什么是 RAII这里是怎么体现的RAII 是 Resource Acquisition Is Initialization也就是资源获取即初始化。它的核心思想是用对象生命周期管理资源生命周期。在这个Socket类中_sockfd是被管理的资源析构函数里调用Close()关闭 fd。这样Socket对象销毁时fd 也会被自动释放。3.bind()、listen()、accept()分别做什么bind()用来把 socket 绑定到本地 IP 和端口。listen()用来把 socket 转换成监听 socket让它可以接收连接请求。accept()用来从内核已完成连接队列中取出一个连接并返回一个新的通信 fd。4.listen(backlog)中的 backlog 是什么backlog表示内核已完成连接队列的长度上限。它不是服务器最大并发连接数。服务器能同时维护多少连接还和系统 fd 数量、内存、事件循环处理能力等因素有关。5. 为什么需要SO_REUSEADDR服务器关闭后连接可能会进入TIME_WAIT状态端口短时间内不能立即复用。开启SO_REUSEADDR后可以减少服务器重启时因为端口占用导致bind()失败的问题。6.SO_REUSEADDR和SO_REUSEPORT有什么区别SO_REUSEADDR更偏向地址复用常用于解决服务端重启后端口无法立即绑定的问题。SO_REUSEPORT允许多个 socket 绑定同一个 IP 和端口常用于多进程服务器让多个进程共同监听同一个端口由内核进行连接分发。7. 为什么高并发服务器需要非阻塞 IO因为事件循环线程不能被单个连接阻塞。如果某个客户端一直不发数据而服务器在这个连接上阻塞等待那么这个线程就无法继续处理其他连接。非阻塞 IO 配合epoll可以让一个线程同时管理大量 fd。8.EAGAIN和EINTR分别表示什么EAGAIN表示当前操作暂时无法完成。在非阻塞读场景中它通常表示当前没有数据可读在非阻塞写场景中它通常表示当前发送缓冲区暂时没有空间。EINTR表示系统调用被信号中断可以根据具体场景选择重试。9.recv()返回 0 代表什么recv()返回 0 通常表示对端已经正常关闭连接。这一点要和EAGAIN区分开。EAGAIN是当前暂时没数据连接并没有关闭而recv()返回 0 是连接已经被对端关闭。10.send()一次一定能发送完所有数据吗不一定。send()返回的是本次实际发送的字节数。尤其在非阻塞模式下如果发送缓冲区空间不足可能只发送一部分数据。所以高并发网络库一般会有输出缓冲区。没发完的数据先保存起来等 fd 再次可写时继续发送。11.MSG_DONTWAIT和O_NONBLOCK有什么区别MSG_DONTWAIT只影响当前这一次recv()或send()调用。O_NONBLOCK是通过fcntl()修改 fd 属性会让这个 fd 后续所有 IO 操作都变成非阻塞。简单来说MSG_DONTWAIT是单次非阻塞O_NONBLOCK是永久非阻塞。12.CreateServer()中为什么地址复用最好放在Bind()前因为SO_REUSEADDR和SO_REUSEPORT影响的是 socket 的绑定行为。如果已经bind()完了再设置可能无法影响本次绑定过程所以更合理的顺序是Create - ReuseAddress - Bind - Listen如果还要设置非阻塞也可以在Bind()前后设置但通常服务端监听 fd 在进入事件循环前设置好即可。13. 当前这个Socket类还有哪些可以继续优化的地方当前版本已经能满足基础封装需求但后续可以继续优化禁用拷贝构造和拷贝赋值避免多个Socket对象管理同一个 fd增加移动构造和移动赋值支持 fd 所有权转移ReuseAddress()、NonBlock()中补充系统调用返回值检查Recv()中区分recv()返回 0 和EAGAIN将block_flag改名为nonblock_flag将ReuseAddress()调整到Bind()之前调用这些优化不影响当前学习主线但在真正写成网络库时会更严谨。总结今天完成了Socket类的基础封装把 Linux socket 编程中常见的系统调用整理到一个类里包括创建、绑定、监听、连接、接收、发送、关闭、非阻塞和地址复用。这个模块本身并不复杂但它是后续网络库的底座。后面实现Acceptor时需要用监听 socket 接收新连接实现TcpConnection时需要用连接 socket 进行读写实现EventLoop时又需要把这些 fd 注册到事件循环中统一管理。从项目推进角度看昨天的Buffer解决的是“数据怎么存”今天的Socket解决的是“数据怎么进出网络”。两者组合起来后面就可以继续往 Reactor 模型的核心模块推进了。