目录

一、什么是Socket? 

二、 Socket API 调用流程

三、Socket 实战:TCP / UDP 完整代码

3.1 TCP 回显服务器 

3.2 、TCP客户端

3.3、UDP收发

四、如何判断一个请求包收完了:粘包与拆包

4.1 现象

4.2 成因

4.3 三种解决方案

五、IO 模型演进:从阻塞到 epoll + Reactor

5.1 五种 IO 模型

5.2 select / poll / epoll 对比

5.3 LT 与 ET

5.4 epoll + 非阻塞 + Reactor 骨架

5.5 Reactor 模式与主从多线程模型


        Socket 不是协议,而是操作系统提供给应用程序的一套网络编程 API 抽象。在 Unix "一切皆文件" 的哲学下,socket 就是一个特殊的文件描述符(fd),指向内核里的一个数据结构,这个结构里最重要的是两个缓冲区:

理解这张图,就能解释很多现象:

• send() 返回成功 ≠ 对方收到了。它只表示"数据已经从用户缓冲区拷贝到内核发送缓冲区"。真正的发送由内核协议栈择机进行。

• send() 可能返回小于请求的字节数(短写)。当发送缓冲区快满时,阻塞模式会阻塞,非阻塞模式会返回已写入的字节数或 EAGAIN。所以必须循环写。

• recv() 返回 0 表示对端优雅关闭(收到 FIN),返回 -1 才是出错。

• 粘包的本质:应用层的"消息边界"信息在写入字节流缓冲区的那一刻就丢失了。

一个 TCP 连接由四元组唯一标识:(源IP, 源端口, 目的IP, 目的端口)。这解释了为什么一个服务器的 80 端口能同时服务几万个客户端——因为客户端的 IP/端口不同,四元组就不同。

二、 Socket API 调用流程

几个容易踩的坑:

• listen(fd, backlog) 的 backlog 在 Linux 2.2 之后指的是全连接队列的长度,实际值取 min(backlog, net.core.somaxconn),后者默认 128,高并发场景要调大。

• accept() 返回的是一个新的 fd,用于和这个客户端通信;监听 fd 继续监听。

• 不设 SO_REUSEADDR,服务器重启时会因为上次连接的 TIME_WAIT 而 bind 失败(Address already in use)。

三、Socket 实战:TCP / UDP 完整代码

        以下代码在 Linux 下 g++ -std=c++11 可直接编译。Windows 下需要 WSAStartup() 并把 close 换成 closesocket。

3.1 TCP 回显服务器 

// tcp_server.cpp   g++ tcp_server.cpp -o server -lpthread
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <cstdio>
#include <thread>
 
constexpr int kPort = 8888;
 
// 循环写,处理短写;返回 true 表示全部写出
static bool sendAll(int fd, const char* buf, size_t len) {
    size_t sent = 0;
    while (sent < len) {
        // MSG_NOSIGNAL:对端已关闭时返回 EPIPE 而不是触发 SIGPIPE 杀掉进程
        ssize_t n = ::send(fd, buf + sent, len - sent, MSG_NOSIGNAL);
        if (n > 0) { sent += static_cast<size_t>(n); continue; }
        if (n < 0 && (errno == EINTR)) continue;
        return false;
    }
    return true;
}
 
static void handleClient(int connFd, sockaddr_in peer) {
    char ip[INET_ADDRSTRLEN] = {0};
    inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
    printf("[+] client %s:%d connected, fd=%d\n", ip, ntohs(peer.sin_port), connFd);
 
    char buf[4096];
    while (true) {
        ssize_t n = ::recv(connFd, buf, sizeof(buf), 0);
        if (n > 0) {
            if (!sendAll(connFd, buf, static_cast<size_t>(n))) break;
        } else if (n == 0) {
            printf("[-] peer closed (FIN), fd=%d\n", connFd);   // 收到 FIN
            break;
        } else {
            if (errno == EINTR) continue;                        // 被信号打断,重试
            perror("recv");
            break;
        }
    }
    ::close(connFd);   // 必须关闭,否则堆积 CLOSE_WAIT
}
 
int main() {
    int listenFd = ::socket(AF_INET, SOCK_STREAM, 0);
    if (listenFd < 0) { perror("socket"); return -1; }
 
    int on = 1;
    setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
 
    sockaddr_in addr{};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);   // 主机序 → 网络序(大端)
    addr.sin_port        = htons(kPort);
 
    if (::bind(listenFd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return -1; }
    if (::listen(listenFd, 1024) < 0) { perror("listen"); return -1; }
    printf("server listening on %d ...\n", kPort);
 
    while (true) {
        sockaddr_in peer{};
        socklen_t   len = sizeof(peer);
        int connFd = ::accept(listenFd, (sockaddr*)&peer, &len);
        if (connFd < 0) { if (errno == EINTR) continue; perror("accept"); break; }
 
        int nodelay = 1;
        setsockopt(connFd, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay));
 
        std::thread(handleClient, connFd, peer).detach();
    }
    ::close(listenFd);
    return 0;
}

3.2 、TCP客户端

// tcp_client.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
 
int main(int argc, char* argv[]) {
    const char* ip   = (argc > 1) ? argv[1] : "127.0.0.1";
    int         port = (argc > 2) ? atoi(argv[2]) : 8888;
 
    int fd = ::socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in srv{};
    srv.sin_family = AF_INET;
    srv.sin_port   = htons(port);
    inet_pton(AF_INET, ip, &srv.sin_addr);
 
    if (::connect(fd, (sockaddr*)&srv, sizeof(srv)) < 0) { perror("connect"); return -1; }
    printf("connected to %s:%d\n", ip, port);
 
    char line[1024];
    while (fgets(line, sizeof(line), stdin)) {
        size_t len = strlen(line);
        if (::send(fd, line, len, 0) != (ssize_t)len) { perror("send"); break; }
 
        char buf[1024] = {0};
        ssize_t n = ::recv(fd, buf, sizeof(buf) - 1, 0);
        if (n <= 0) { printf("server closed\n"); break; }
        printf("echo: %s", buf);
    }
    ::close(fd);
    return 0;
}

3.3、UDP收发

// udp_server.cpp —— 注意:没有 listen/accept,也不存在"连接"
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstdio>
 
int main() {
    int fd = ::socket(AF_INET, SOCK_DGRAM, 0);      // SOCK_DGRAM 是关键区别
    sockaddr_in addr{};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port        = htons(9999);
    ::bind(fd, (sockaddr*)&addr, sizeof(addr));
    printf("udp server on 9999\n");
 
    char buf[1500];
    while (true) {
        sockaddr_in peer{};
        socklen_t   len = sizeof(peer);
        // recvfrom 一次恰好读一个数据报,天然保留消息边界
        ssize_t n = ::recvfrom(fd, buf, sizeof(buf), 0, (sockaddr*)&peer, &len);
        if (n < 0) continue;
        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
        printf("recv %zd bytes from %s:%d\n", n, ip, ntohs(peer.sin_port));
        ::sendto(fd, buf, n, 0, (sockaddr*)&peer, len);
    }
}

        字节序提醒:网络传输统一使用大端序(Big-Endian),而 x86/ARM 通常是小端。所有多字节整数(端口、长度字段)都必须用 htons/htonl/ntohs/ntohl 转换。自定义二进制协议时忘记转字节序,是跨平台通信最常见的低级 BUG。

四、如何判断一个请求包收完了:粘包与拆包

        这是 TCP 编程绕不开的问题,也是面试必问。

4.1 现象

4.2 成因

• 发送方:Nagle 算法把小包合并;发送缓冲区未满时数据积压。

• 接收方:应用层读取速度慢于到达速度,缓冲区积压多个包。

• 协议本身:超过 MSS 的数据必然被 TCP 分段。

根本原因只有一个:TCP 是面向字节流的协议,它不保存也不理解应用层的消息边界。 所以解决方案必须在应用层。

4.3 三种解决方案

方案一:定长包。简单粗暴,浪费带宽,只适用于固定格式的控制指令。

方案二:分隔符。HTTP 头部用 \r\n\r\n、Redis RESP 用 \r\n。缺点是消息体中出现分隔符需要转义,且解析需要逐字节扫描。

方案三:长度字段(TLV,最通用)。头部固定长度,其中包含 body 长度。这是绝大多数二进制私有协议、gRPC、Protobuf 帧、RTMP 的做法。

        Magic Number 用于快速校验和"重同步"——一旦解析错位,可以扫描寻找下一个 Magic 恢复。

4.4 完整的解包状态机实现

        关键思路:每个连接维护一个独立的接收缓冲区,recv() 到的数据先追加进去,然后循环尝试从缓冲区中"切"出完整的包,切不出来就等下次数据到达。

#include <cstdint>
#include <vector>
#include <string>
#include <arpa/inet.h>
 
#pragma pack(push, 1)     // 关闭内存对齐,保证 sizeof(PacketHeader)==10
struct PacketHeader {
    uint32_t magic;       // 0xABCD1234
    uint8_t  version;
    uint8_t  type;
    uint32_t length;      // payload 长度(网络字节序)
};
#pragma pack(pop)
 
constexpr uint32_t kMagic     = 0xABCD1234;
constexpr uint32_t kMaxBody   = 16 * 1024 * 1024;   // 防御:拒绝异常大包,避免内存被打爆
 
class PacketDecoder {
public:
    // 把新到的数据喂进来
    void feed(const char* data, size_t len) { buf_.append(data, len); }
 
    // 尝试取出一个完整包;返回 false 表示数据还不够
    bool nextPacket(PacketHeader& hdr, std::string& body) {
        while (true) {
            if (buf_.size() < sizeof(PacketHeader)) return false;      // ① 包头都不够
 
            const PacketHeader* p = reinterpret_cast<const PacketHeader*>(buf_.data());
            uint32_t magic  = ntohl(p->magic);
            uint32_t bodyLen = ntohl(p->length);
 
            if (magic != kMagic) {                                     // ② 错位:丢一个字节重同步
                buf_.erase(0, 1);
                continue;
            }
            if (bodyLen > kMaxBody) { buf_.clear(); return false; }    // ③ 非法长度,断开连接更稳妥
 
            size_t total = sizeof(PacketHeader) + bodyLen;
            if (buf_.size() < total) return false;                     // ④ 半包,等下次
 
            hdr         = *p;
            hdr.magic   = magic;
            hdr.length  = bodyLen;
            body.assign(buf_.data() + sizeof(PacketHeader), bodyLen);
            buf_.erase(0, total);                                      // ⑤ 消费掉这个包
            return true;
        }
    }
private:
    std::string buf_;   // 生产环境建议换成环形缓冲区,避免 erase 的 O(n) 拷贝
};
 
// 使用方式
void onReadable(int fd, PacketDecoder& dec) {
    char tmp[65536];
    ssize_t n = ::recv(fd, tmp, sizeof(tmp), 0);
    if (n <= 0) return;                 // 0=对端关闭  <0=错误(注意 EAGAIN)
    dec.feed(tmp, n);
 
    PacketHeader hdr;
    std::string  body;
    while (dec.nextPacket(hdr, body)) { // 关键:while 而不是 if,一次读可能含多个包
        handleBusiness(hdr, body);
    }
}

几个工程要点:

1. 必须用 while 循环取包,因为一次 recv 可能含 N 个完整包。

2. 必须校验 length 的合法性,否则攻击者发一个 length = 0xFFFFFFFF 就能让你 OOM。

3. 生产环境用环形缓冲区(RingBuffer)或 muduo 那种 readIndex/writeIndex 双游标 Buffer,避免 erase(0, n) 造成的频繁内存搬移。

4. HTTP 是"分隔符 + 长度字段"的混合体:先找 \r\n\r\n 定位头部结束,再读 Content-Length 决定 body 长度;如果是 Transfer-Encoding: chunked,则按块解析直到 0\r\n\r\n。

五、IO 模型演进:从阻塞到 epoll + Reactor

5.1 五种 IO 模型

        前四种都属于"同步 IO",因为真正的数据拷贝(内核缓冲区 → 用户缓冲区)阶段仍然是阻塞的。

5.2 select / poll / epoll 对比

        epoll 高效的核心在于把"注册"和"等待"分离了:epoll_ctl 一次性把 fd 挂到内核红黑树上,网卡中断触发时内核通过回调把就绪 fd 塞进就绪链表,epoll_wait 只需要看链表空不空。所以它的性能不随监听 fd 总数增长而下降,只和活跃连接数有关。这正是 C10K/C1000K 问题的答案。

5.3 LT 与 ET

• LT(水平触发,默认):只要缓冲区还有数据可读,每次 epoll_wait 都会返回该 fd。编程简单、不容易丢事件,可以一次只读一部分。

• ET(边缘触发):只在状态发生变化(有新数据到达)时通知一次。必须一次性把数据读干净(循环 read 直到返回 EAGAIN),否则剩余数据将永远收不到通知。ET 必须配合非阻塞 fd,否则最后一次 read 会阻塞住整个事件循环。

5.4 epoll + 非阻塞 + Reactor 骨架

// epoll_server.cpp   g++ epoll_server.cpp -o eserver
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <unordered_map>
 
static void setNonBlocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
 
int main() {
    int listenFd = socket(AF_INET, SOCK_STREAM, 0);
    int on = 1;
    setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
 
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(8888);
    bind(listenFd, (sockaddr*)&addr, sizeof(addr));
    listen(listenFd, 1024);
    setNonBlocking(listenFd);
 
    int epfd = epoll_create1(EPOLL_CLOEXEC);
    epoll_event ev{};
    ev.events  = EPOLLIN;                  // 监听 fd 用 LT,避免"惊群"下漏 accept
    ev.data.fd = listenFd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenFd, &ev);
 
    constexpr int kMaxEvents = 1024;
    epoll_event events[kMaxEvents];
 
    while (true) {
        int n = epoll_wait(epfd, events, kMaxEvents, -1);
        if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; }
 
        for (int i = 0; i < n; ++i) {
            int fd = events[i].data.fd;
 
            if (fd == listenFd) {
                // 循环 accept,把已完成队列一次取空
                while (true) {
                    int connFd = accept4(listenFd, nullptr, nullptr, SOCK_NONBLOCK);
                    if (connFd < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;  // 取完了
                        if (errno == EINTR) continue;
                        break;
                    }
                    epoll_event cev{};
                    cev.events  = EPOLLIN | EPOLLET;   // 连接 fd 用 ET,减少唤醒次数
                    cev.data.fd = connFd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, connFd, &cev);
                }
                continue;
            }
 
            if (events[i].events & (EPOLLHUP | EPOLLERR)) {
                epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
                close(fd);
                continue;
            }
 
            if (events[i].events & EPOLLIN) {
                bool closed = false;
                char buf[65536];
                // ET 模式必须读到 EAGAIN 为止
                while (true) {
                    ssize_t r = read(fd, buf, sizeof(buf));
                    if (r > 0) {
                        write(fd, buf, r);       // 生产环境应写入输出缓冲并关注 EPOLLOUT
                    } else if (r == 0) {
                        closed = true; break;    // 对端关闭
                    } else {
                        if (errno == EINTR) continue;
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;  // 读干净了
                        closed = true; break;
                    }
                }
                if (closed) {
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
                    close(fd);
                }
            }
        }
    }
    close(listenFd);
    return 0;
}

5.5 Reactor 模式与主从多线程模型

        上面的单线程 epoll 就是最简单的 Reactor。工业级方案(Netty、muduo、nginx)通常采用 主从 Reactor 多线程:

        核心原则是 one loop per thread:每个连接固定归属一个 SubReactor 线程处理,读写和状态变更都在同一线程内完成,从而天然避免了对连接状态加锁。这是高性能网络库最重要的设计决策,也是"用架构消除锁竞争"的经典案例。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐