跳转至

Linux 网络编程基础

socket API 全景 + 一个能跑的 epoll 回显服务器。系统编程基础 的续篇:fd 概念、进程、信号那套直接沿用。

TCP socket 流程全景

        服务端                              客户端
   socket()  创建 fd                  socket()
      │                                  │
   bind()    绑定 IP:端口                 │
      │                                  │
   listen()   开始监听                     │
      │                                  │
   accept()   阻塞等连接  ◄──── 建立连接 ──── connect()
      │                                  │
   read() ◄────────── 数据流 ──────────► write()
      │                                  │
   close() ──── 四次挥手(双方各自 close)──── close()

服务端 + 客户端最小代码

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>

int main() {
    // 1. 创建 socket:AF_INET=IPv4, SOCK_STREAM=TCP
    int lfd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. SO_REUSEADDR:重启服务时跳过 TIME_WAIT 占用,服务必设
    int one = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));

    // 3. 绑定地址。注意字节序:端口和 IP 都要用 htons/htonl 转网络序
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);   // 监听所有网卡
    addr.sin_port = htons(9000);
    bind(lfd, (sockaddr*)&addr, sizeof(addr));

    // 4. listen:第二个参数是半连接队列长度(backlog)
    listen(lfd, 128);

    // 5. accept:阻塞直到有客户端连进来,返回【新的】fd 与该连接通信
    int cfd = accept(lfd, nullptr, nullptr);

    // 6. 读写。read/write 返回值:>0 实际字节数,0 对端关闭,-1 出错
    char buf[4096];
    ssize_t n = read(cfd, buf, sizeof(buf));
    write(cfd, buf, n);          // 原样回显(生产代码要处理部分写,见下)

    close(cfd);
    close(lfd);
}
// ---- 客户端:不需要 bind/listen/accept,connect 即发起 ----
int cfd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(9000);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);   // 点分 IP → 二进制
connect(cfd, (sockaddr*)&addr, sizeof(addr));
write(cfd, "ping", 4);
char buf[16];
ssize_t n = read(cfd, buf, sizeof(buf));            // 收到 "ping"

read 返回 0 ≠ 出错,-1 才是

read() == 0 是对端正常关闭(EOF),该收尾收尾;-1 看 errno:EAGAIN(非阻塞下没数据,继续)、EINTR(被信号打断,重试)、ECONNRESET(对端异常断开)。把 0 和 -1 混着处理是新手服务最容易崩的地方。

一个能跑的 epoll 回显服务器

单线程支撑上万连接的关键:把 fd 交给内核监视,就绪的才处理。select → poll → epoll 的演进就是"每次全量传 fd 数组"到"内核维护红黑树 + 就绪链表":

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdlib>
#include <cstring>

#define MAX_EVENTS 64

int main(int argc, char* argv[]) {
    int port = argc > 1 ? atoi(argv[1]) : 9000;

    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    int one = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));

    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(port);
    bind(lfd, (sockaddr*)&addr, sizeof(addr));
    listen(lfd, 128);

    // 1. 创建 epoll 实例(内核数据结构句柄)
    int ep = epoll_create1(0);

    // 2. 把监听 fd 挂上 epoll,关注"可读"事件
    epoll_event ev{.events = EPOLLIN, .data.fd = lfd};
    epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev);

    epoll_event events[MAX_EVENTS];
    for (;;) {
        // 3. 阻塞等待,只返回就绪的 fd(-1 = 一直等)
        int n = epoll_wait(ep, events, MAX_EVENTS, -1);
        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;
            if (fd == lfd) {
                // 监听 fd 可读 = 有新连接,accept 后同样挂进 epoll
                int cfd = accept(lfd, nullptr, nullptr);
                ev.events = EPOLLIN;
                ev.data.fd = cfd;
                epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &ev);
            } else {
                // 连接 fd 可读 = 有数据(或对端关闭)
                char buf[4096];
                ssize_t nread = read(fd, buf, sizeof(buf));
                if (nread <= 0) {
                    close(fd);                 // 0=EOF,-1=错误:两种都关掉清理
                } else {
                    write(fd, buf, nread);     // 回显(生产代码要处理部分写)
                }
            }
        }
    }
}

LT 与 ET

模式 行为 注意
LT(默认) 只要还有数据没读完,每次 epoll_wait 都会通知 容错好,一次读不完没事
EPOLLET 只在状态变化时通知一次 必须循环读到 EAGAIN,漏读就"卡死"

新项目用 LT 起步最稳;ET + 非阻塞 + 循环读写是高性能服务(nginx 风格)的写法。

应用层协议:read 不保消息边界

TCP 是字节流——两次 write 对端可能一次收到,一次长的可能分三次到。必须自己定义消息边界,两种基本做法:

1. 定界符:每条消息以 '\n' 结尾(简单,但内容需转义)
2. 长度前缀:先 4 字节长度,再消息体(二进制协议标准做法)
   [len:4B][payload:len B][len:4B][payload]...

黏包/半包处理是所有 TCP 应用的第一课;UDP 则天然保留消息边界(但可能丢、乱序,需要应用层自己兜)。

常用 socket 选项与模式

需求 做法
重启免 TIME_WAIT 等待 SO_REUSEADDR(服务端必设)
心跳探测死连接 SO_KEEPALIVE + TCP_KEEPIDLE/INTVL/CNT
关闭 Nagle(要低延迟) TCP_NODELAY
读超时 SO_RCVTIMEO 或 poll(fd, POLLIN, timeout)
非阻塞模式 fcntl(fd, F_SETFL, O_NONBLOCK)(ET 必备)
传递凭据/大文件 sendfile() 零拷贝、recvmsg 传 fd

排错路标

  • netstat -s / ss -ti:重传、拥塞窗口、RTT;
  • 抓包:tcpdump -i any port 9000 -nn -A(见排查工具箱网络一节);
  • ss -lnt 看 backlog 是否打满(Recv-Q 接近 Send-Q);
  • 本机端口被占:lsof -i:9000,SO_REUSEADDR 不是让你绑定已占用端口的。

完整的生产服务器还需要:优雅退出(信号处理见系统编程)、连接对象的生命周期管理(C++ 侧见智能指针的 enable_shared_from_this)、多线程 + epoll 的任务分发(Reactor 模式)——逐步展开。

评论