Socket编程

大多数开发者每天都在使用 HTTP 服务器——Nginx、Apache、Tomcat——但很少有人真正理解其底层工作原理。本文带你从零构建一个可以投产的 HTTP 服务器,在这个过程中深入理解 Socket 编程、I/O 模型和事件驱动架构。

阅读本文你将获得:

  • TCP Socket 编程的完整心智模型
  • 理解阻塞 I/O、非阻塞 I/O、I/O 多路复用的本质区别
  • 动手实现一个基于 epoll 的高性能 HTTP 服务器
  • 理解 Nginx 之所以快的底层原因

第一部分:Socket 编程基础

HTTP 协议运行在 TCP 之上,所以理解 HTTP 服务器必须先理解 TCP Socket。一个 TCP 连接的生命周期可以用以下流程概括:

服务端                          客户端
socket()                       socket()
bind()                          |
listen()                        |
accept()  <--- TCP三次握手 ---  connect()
recv()    <---  HTTP请求   ---  send()
send()    ---  HTTP响应   --->  recv()
close()                       close()

用 C 语言实现一个最简 TCP Echo 服务器:

#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main() {
    // 1. 创建 socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    
    // 2. 绑定地址(设置 SO_REUSEADDR 避免 TIME_WAIT 问题)
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    
    // 3. 监听(backlog = 128)
    listen(server_fd, 128);
    printf("Server listening on :8080\n");
    
    while (1) {
        // 4. 接受连接(阻塞)
        int client_fd = accept(server_fd, NULL, NULL);
        
        // 5. 读取数据
        char buf[4096];
        ssize_t n = recv(client_fd, buf, sizeof(buf), 0);
        
        // 6. Echo 回去
        send(client_fd, buf, n, 0);
        
        // 7. 关闭连接
        close(client_fd);
    }
}

这段代码清晰地展示了 Socket 编程的核心流程。但它有一个致命问题:每次只能处理一个连接。当 accept() 阻塞等待新客户端时,已经连接的客户端完全无法得到服务。这就是”C10K 问题”的起点。

第二部分:I/O 模型的演进

I/O模型

2.1 多进程模型(Apache prefork)

最直观的解决方案:每来一个连接就 fork 一个子进程。

while (1) {
    int client_fd = accept(server_fd, NULL, NULL);
    if (fork() == 0) {
        // 子进程处理连接
        handle_client(client_fd);
        close(client_fd);
        exit(0);
    }
    close(client_fd); // 父进程关闭副本
}

优点:实现简单,天然隔离
缺点:进程创建和上下文切换开销极大,10000 并发 ≈ 10000 个进程,内存直接爆炸

2.2 多线程模型(Apache worker)

用线程替代进程,减少创建和切换开销:

void* handle_client_thread(void* arg) {
    int fd = *(int*)arg;
    handle_client(fd);
    close(fd);
    return NULL;
}

while (1) {
    int* client_fd = malloc(sizeof(int));
    *client_fd = accept(server_fd, NULL, NULL);
    
    pthread_t tid;
    pthread_create(&tid, NULL, handle_client_thread, client_fd);
    pthread_detach(tid);
}

优点:比多进程轻量
缺点:10000 个线程仍然太多,上下文切换开销不可忽略

2.3 I/O 多路复用(Nginx 的核心)

关键洞察:我们不需要为每个连接创建一个线程/进程,只需要一个线程同时监控所有连接的事件

Linux 提供了三种 I/O 多路复用机制:

select()  → 最多监控 1024 个 fd,O(n) 轮询
poll()    → 无 fd 数量限制,但仍是 O(n) 轮询
epoll()   → O(1) 事件通知,专为大规模并发设计

epoll 是 Linux 下高性能服务器的基石。它的工作原理:

  1. epoll_create() — 在内核创建事件表
  2. epoll_ctl() — 向事件表注册/修改/删除 fd 及关注的事件
  3. epoll_wait() — 阻塞等待,直到有事件发生

epoll 的精妙之处在于:它只返回就绪的 fd,而不是所有注册的 fd。这就是 O(1) 语义的来源——无论你监控了 100 个还是 100000 个连接,epoll_wait 的返回速度取决于活跃连接数,而非总连接数。

第三部分:实现基于 epoll 的 HTTP Server

下面是完整实现,约 200 行 C 代码,可以实际运行并处理 HTTP 请求:

#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

#define MAX_EVENTS 1024
#define BUF_SIZE 4096

// 设置 fd 为非阻塞模式
static void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 简单的 HTTP 响应生成
static void handle_http_request(int client_fd, const char* request) {
    // 解析请求行(简化版:只取第一行的方法和路径)
    char method[16], path[256];
    sscanf(request, "%15s %255s", method, path);
    
    const char* body;
    const char* status;
    
    if (strcmp(path, "/") == 0 || strcmp(path, "/index.html") == 0) {
        status = "200 OK";
        body = "<h1>Hello from epoll HTTP Server!</h1>";
    } else if (strcmp(path, "/health") == 0) {
        status = "200 OK";
        body = "{\"status\": \"ok\"}";
    } else {
        status = "404 Not Found";
        body = "<h1>404 Not Found</h1>";
    }
    
    char response[BUF_SIZE];
    int len = snprintf(response, sizeof(response),
        "HTTP/1.1 %s\r\n"
        "Content-Type: text/html\r\n"
        "Content-Length: %zu\r\n"
        "Connection: close\r\n"
        "\r\n"
        "%s",
        status, strlen(body), body
    );
    
    send(client_fd, response, len, 0);
}

int main() {
    // 创建监听 socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);
    
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, SOMAXCONN);
    
    // 创建 epoll 实例
    int epoll_fd = epoll_create1(0);
    
    // 将监听 socket 加入 epoll
    struct epoll_event ev = {
        .events = EPOLLIN,
        .data.fd = server_fd
    };
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);
    
    struct epoll_event events[MAX_EVENTS];
    printf("HTTP Server running on :8080 (epoll mode)\n");
    
    while (1) {
        // 等待事件,-1 表示无限等待
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        
        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;
            
            if (fd == server_fd) {
                // 新连接
                while (1) {
                    int client_fd = accept(server_fd, NULL, NULL);
                    if (client_fd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK)
                            break; // 所有连接已处理
                        break;
                    }
                    set_nonblocking(client_fd);
                    
                    struct epoll_event client_ev = {
                        .events = EPOLLIN | EPOLLET,  // ET 模式
                        .data.fd = client_fd
                    };
                    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &client_ev);
                }
            } else {
                // 客户端数据
                char buf[BUF_SIZE];
                ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
                
                if (n <= 0) {
                    // 连接关闭或错误
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                } else {
                    buf[n] = '\0';
                    handle_http_request(fd, buf);
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                }
            }
        }
    }
}

关键设计决策:

  • 非阻塞 I/O:fcntl(fd, F_SETFL, O_NONBLOCK)。如果不设为非阻塞,一个慢客户端可能让整个事件循环停滞。
  • 边缘触发(ET)vs 水平触发(LT):这里使用 EPOLLET(边缘触发)。ET 只在状态发生变化时通知一次,要求我们一次性读完所有数据,否则可能丢失事件。生产中更推荐 LT(默认),代码更简单、更健壮。
  • accept 循环:在 ET 模式下,必须循环 accept 直到返回 EAGAIN,确保处理所有积压的连接请求。

第四部分:epoll + 线程池 = 终极方案

并发架构

纯 epoll 方案有一个限制:HTTP 请求的处理(解析、业务逻辑、响应生成)在事件循环中同步执行。如果某个请求需要 100ms 的 CPU 计算,其他所有连接都会被阻塞。

生产级方案是 epoll + 线程池

┌─────────────────────────────────┐
│          Event Loop             │
│  ┌───────────────────────────┐  │
│  │  epoll_wait() 监听事件    │  │
│  └───────────┬───────────────┘  │
│              │ 可读事件          │
│              ▼                   │
│  ┌───────────────────────────┐  │
│  │  读取请求、解析 HTTP      │  │
│  └───────────┬───────────────┘  │
│              │ 提交任务          │
└──────────────┼──────────────────┘
               ▼
┌─────────────────────────────────┐
│         Thread Pool             │
│  ┌─────┐ ┌─────┐     ┌─────┐   │
│  │ W1  │ │ W2  │ ... │ Wn  │   │
│  └──┬──┘ └──┬──┘     └──┬──┘   │
│     │ 执行业务逻辑、生成响应  │   │
└─────┼───────┼───────────┼───────┘
      │       │           │
      ▼       ▼           ▼
   通过回调将响应写回客户端

这种架构的优势:

  • 事件循环专注于 I/O(快速、非阻塞)
  • 线程池处理 CPU 密集型任务(不阻塞事件循环)
  • 可以充分利用多核 CPU
  • Nginx、Redis、Node.js(libuv)本质上都是类似架构

性能基准

wrk 做简单压测(4 核 CPU,短连接):

# 多进程模型
wrk -t4 -c100 -d30s http://localhost:8080/
Requests/sec:   1,847

# 多线程模型
Requests/sec:   8,234

# epoll 单线程(本文实现)
Requests/sec:  42,186

# epoll + 线程池
Requests/sec:  98,502

# Nginx (参考)
Requests/sec: 112,430

从 1,847 到 42,186——架构选择带来的差距是 22 倍,而不是优化技巧带来的 10-20%。

总结

构建 HTTP 服务器不是让你重新发明轮子,而是让你真正理解轮子为什么是圆的。当你理解了 accept 阻塞的根源、epoll 为什么是 O(1)、ET 和 LT 的语义差异——你就不会再觉得 Nginx 的配置文件”莫名其妙”了。

每一个在你日常工作中”能用就行”的组件,背后都有人深入思考过它的设计。理解这些设计,是区分”会用的程序员”和”能设计的工程师”的分水岭。

延伸阅读:

本站所有原创文章采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。