{T}

实战篇答疑:高性能网络编程框架设计要点

本篇为实战篇的答疑部分,针对框架设计中的关键问题进行集中解答。

为什么发送数据时先尝试直接通过套接字发送,再由框架接管?

问题描述

当应用程序需要发送数据时,在完成数据读取和回应编码后,调用 tcp_connection_send_buffer 方法发送数据:

c
// 数据读到 buffer 之后的 callback
int onMessage(struct buffer *input, struct tcp_connection *tcpConnection) {
    printf("get message from tcp connection %s\n", tcpConnection->name);
    printf("%s", input->data);

    struct buffer *output = buffer_new();
    int size = buffer_readable_size(input);
    for (int i = 0; i < size; i++) {
        buffer_append_char(output, rot13_char(buffer_read_char(input)));
    }
    tcp_connection_send_buffer(tcpConnection, output);
    return 0;
}

tcp_connection_send_buffer 调用 tcp_connection_send_data 发送数据:

c
int tcp_connection_send_buffer(struct tcp_connection *tcpConnection, struct buffer *buffer) {
    int size = buffer_readable_size(buffer);
    int result = tcp_connection_send_data(tcpConnection, buffer->data + buffer->readIndex, size);
    buffer->readIndex += size;
    return result;
}

tcp_connection_send_data 中,若当前 channel 未注册 WRITE 事件且发送缓冲区无待发送数据,则直接调用 write 函数发送:

c
// 应用层调用入口
int tcp_connection_send_data(struct tcp_connection *tcpConnection, void *data, int size) {
    size_t nwrited = 0;
    size_t nleft = size;
    int fault = 0;

    struct channel *channel = tcpConnection->channel;
    struct buffer *output_buffer = tcpConnection->output_buffer;

    // 先尝试直接往套接字发送
    if (!channel_write_event_is_enabled(channel) && buffer_readable_size(output_buffer) == 0) {
        nwrited = write(channel->fd, data, size);
        if (nwrited >= 0) {
            nleft = nleft - nwrited;
        } else {
            nwrited = 0;
            if (errno != EWOULDBLOCK) {
                if (errno == EPIPE || errno == ECONNRESET) {
                    fault = 1;
                }
            }
        }
    }

    if (!fault && nleft > 0) {
        // 拷贝到 Buffer 中,数据由框架接管
        buffer_append(output_buffer, data + nwrited, nleft);
        if (!channel_write_event_is_enabled(channel)) {
            channel_write_event_enable(channel);
        }
    }

    return nwrited;
}

为何不统一将数据写入发送缓冲区,再注册 WRITE 事件由框架统一发送?

设计考量:发送效率

核心原因是为提升发送效率,采用 Fast Path + Slow Path 双路径策略。

应用层读取数据并编码后,buffer 对象是应用层创建的,数据驻留在应用层的 buffer 中。tcp_connection_send_data 中的 data 参数指向的是应用层缓冲的数据,而非 tcp_connection 对象内部的 buffer。

若跳过直接发送步骤,将数据交给 tcp_connectionoutput_buffer,则需执行一次数据拷贝(发生在 buffer_append 中):

c
int buffer_append(struct buffer *buffer, void *data, int size) {
    if (data != NULL) {
        make_room(buffer, size);
        // 拷贝数据到可写空间中
        memcpy(buffer->data + buffer->writeIndex, data, size);
        buffer->writeIndex += size;
    }
}

而增加直接发送判断后,在满足条件时跳过了该拷贝步骤,直接将数据写入套接字发送缓冲区:

c
// 先尝试直接往套接字发送
if (!channel_write_event_is_enabled(channel) && buffer_readable_size(output_buffer) == 0) {
        nwrited = write(channel->fd, data, size)
        ...

在绝大多数场景下,直接发送已满足需求,无需将数据拷贝到 tcp_connectionoutput_buffer 中。

若不满足直接发送条件(如已注册 WRITE 事件或 output_buffer 中有待发送数据),则将数据拷贝到 output_buffer,由 event_loop 的回调驱动 handle_write 将数据从 output_buffer 发往套接字缓冲区:

c
// 发送缓冲区可写,将 output_buffer 不断往外发送
int handle_write(void *data) {
    struct tcp_connection *tcpConnection = (struct tcp_connection *) data;
    struct event_loop *eventLoop = tcpConnection->eventLoop;
    assertInSameThread(eventLoop);

    struct buffer *output_buffer = tcpConnection->output_buffer;
    struct channel *channel = tcpConnection->channel;

    ssize_t nwrited = write(channel->fd, output_buffer->data + output_buffer->readIndex,
                            buffer_readable_size(output_buffer));
    if (nwrited > 0) {
        // 已读 nwrited 字节
        output_buffer->readIndex += nwrited;
        // 若数据完全发送,取消 WRITE 事件
        if (buffer_readable_size(output_buffer) == 0) {
            channel_write_event_disable(channel);
        }
        // 回调 writeCompletedCallBack
        if (tcpConnection->writeCompletedCallBack != NULL) {
            tcpConnection->writeCompletedCallBack(tcpConnection);
        }
    } else {
        yolanda_msgx("handle_write for tcp connection %s", tcpConnection->name);
    }
}
图表渲染中…

总结:在网络条件良好时,数据直接发送至套接字缓冲区,避免一次内存拷贝;当网络条件变差或待发送数据量过大导致一次发送无法完成时,数据由框架缓冲到 output_buffer 中,由事件分发机制负责后续发送。

关于回调函数的两层设计

epoll-server-multithreads.c 中定义了 onMessageonConnectionCompleted 等回调函数用于创建 TCPServer,而在 tcp_connection 内部又实现了 handle_readhandle_write 等事件回调。为何需要两层回调?

第一层:框架与应用程序的编程接口

第一层回调是框架定义的、面向连接生命周期管理的接口,可视为编程连接点(Program Hook Point)。类似于面向对象中的抽象类,框架提供编程入口,应用程序填充具体实现。

c
struct tcp_connection {
    struct event_loop *eventLoop;
    struct channel *channel;
    char *name;
    struct buffer *input_buffer;   // 接收缓冲区
    struct buffer *output_buffer;  // 发送缓冲区

    connection_completed_call_back connectionCompletedCallBack;
    message_call_back messageCallBack;
    write_completed_call_back writeCompletedCallBack;
    connection_closed_call_back connectionClosedCallBack;

    void * data; // for callback use: http_server
    void * request; // for callback use
    void * response; // for callback use
};

四个回调函数的语义:

回调函数触发时机
connectionCompletedCallBack连接建立完成后
messageCallBack报文读取并接收到 input 缓冲区后
writeCompletedCallBack报文发送到套接字缓冲区后
connectionClosedCallBack连接关闭时

这一层回调是框架与应用程序约定的接口,实现由应用程序完成,框架在合适时机调用。例如,连接建立成功时回调 connectionCompletedCallBack

c
struct tcp_connection *
tcp_connection_new(int connected_fd, struct event_loop *eventLoop,
connection_completed_call_back connectionCompletedCallBack,
connection_closed_call_back connectionClosedCallBack,
message_call_back messageCallBack,
write_completed_call_back writeCompletedCallBack) {
    ...
    // add event read for the new connection
    struct channel *channel1 = channel_new(connected_fd, EVENT_READ, handle_read, handle_write, tcpConnection);
    tcpConnection->channel = channel1;

    // connectionCompletedCallBack callback
    if (tcpConnection->connectionCompletedCallBack != NULL) {
        tcpConnection->connectionCompletedCallBack(tcpConnection);
    }

   ...
}

第二层:基于事件分发机制的 I/O 回调

第二层回调基于 epoll/poll 事件分发机制。通过注册读写事件,在实际事件发生时由事件分发机制保证对应的回调函数被及时调用,完成基于事件机制的网络 I/O 处理。

连接建立后,创建对应的 channel 对象并赋予读写回调函数:

c
// add event read for the new connection
struct channel *channel1 = channel_new(connected_fd, EVENT_READ, handle_read, handle_write, tcpConnection);

handle_read 函数对应用程序屏蔽了套接字的读操作,将数据缓冲到 tcp_connectioninput_buffer 中,同时起到编程连接点与框架耦合器的作用——分别调用了 messageCallBackconnectionClosedCallBack,将应用程序代码"代入"框架执行:

c
int handle_read(void *data) {
    struct tcp_connection *tcpConnection = (struct tcp_connection *) data;
    struct buffer *input_buffer = tcpConnection->input_buffer;
    struct channel *channel = tcpConnection->channel;

    if (buffer_socket_read(input_buffer, channel->fd) > 0) {
        // 应用程序真正读取 Buffer 里的数据
        if (tcpConnection->messageCallBack != NULL) {
            tcpConnection->messageCallBack(input_buffer, tcpConnection);
        }
    } else {
        handle_connection_closed(tcpConnection);
    }
}

handle_write 函数负责将 tcp_connection 对象的 output_buffer 源源不断地送往套接字发送缓冲区:

c
// 发送缓冲区可写,将 output_buffer 不断往外发送
int handle_write(void *data) {
    struct tcp_connection *tcpConnection = (struct tcp_connection *) data;
    struct event_loop *eventLoop = tcpConnection->eventLoop;
    assertInSameThread(eventLoop);

    struct buffer *output_buffer = tcpConnection->output_buffer;
    struct channel *channel = tcpConnection->channel;

    ssize_t nwrited = write(channel->fd, output_buffer->data + output_buffer->readIndex,
                            buffer_readable_size(output_buffer));
    if (nwrited > 0) {
        output_buffer->readIndex += nwrited;
        if (buffer_readable_size(output_buffer) == 0) {
            channel_write_event_disable(channel);
        }
        if (tcpConnection->writeCompletedCallBack != NULL) {
            tcpConnection->writeCompletedCallBack(tcpConnection);
        }
    } else {
        yolanda_msgx("handle_write for tcp connection %s", tcpConnection->name);
    }
}

两层回调的关系

图表渲染中…

第一层回调是面向应用程序的语义接口,第二层回调是面向事件分发机制的 I/O 处理接口。第二层回调在完成 I/O 操作后,调用第一层回调将控制权交给应用程序。

tcp_connection 与 channel 的联系和区别

tcp_connectionchannel 存在紧密联系,但单独设计 tcp_connection 对象是必要的,原因如下:

  1. 语义完整性:需要在暴露给应用程序的回调函数中传递具有现实语义的数据结构,该结构需携带套接字、缓冲区等信息。channel 对象过于单薄,与连接的语义相去甚远。

  2. 抽象层次channel 是通用的事件抽象——acceptor、socketpair 等都是 channel,只要能引起事件的发生和传递即可作为 channel。将 channel 作为内部实现细节,不通过回调函数暴露给应用程序,有利于保持接口清晰。

  3. 上下文扩展:在实现 HTTP 功能时,需在上下文中保存 http_requesthttp_response 数据,这些数据放在 channel 中不合适,而 tcp_connection 提供了 datarequestresponse 等扩展字段。

c
struct tcp_connection {
    struct event_loop *eventLoop;
    struct channel *channel;
    char *name;
    struct buffer *input_buffer;   // 接收缓冲区
    struct buffer *output_buffer;  // 发送缓冲区

    connection_completed_call_back connectionCompletedCallBack;
    message_call_back messageCallBack;
    write_completed_call_back writeCompletedCallBack;
    connection_closed_call_back connectionClosedCallBack;

    void * data; // for callback use: http_server
    void * request; // for callback use
    void * response; // for callback use
};

总结:每个 tcp_connection 对象一定包含一个 channel 对象,而 channel 对象未必是一个 tcp_connection。这是组合模式(Composition Pattern)的典型应用。

图表渲染中…

主线程等待子线程的同步锁问题

问题

若加锁的目的是让主线程等待子线程初始化 event_loop,不加锁而使用 while 循环不断判断 event_loop 是否为 NULL 是否也可达到目的?

c
// 由主线程调用,初始化子线程并使其开始运行 event_loop
struct event_loop *event_loop_thread_start(struct event_loop_thread *eventLoopThread) {
    pthread_create(&eventLoopThread->thread_tid, NULL, &event_loop_thread_run, eventLoopThread);

    assert(pthread_mutex_lock(&eventLoopThread->mutex) == 0);

    while (eventLoopThread->eventLoop == NULL) {
        assert(pthread_cond_wait(&eventLoopThread->cond, &eventLoopThread->mutex) == 0);
    }
    assert(pthread_mutex_unlock(&eventLoopThread->mutex) == 0);

    yolanda_msgx("event loop thread started, %s", eventLoopThread->thread_name);
    return eventLoopThread->eventLoop;
}

解答

不加锁而使用 busy-waiting(忙等待)循环判断存在两个问题:

  1. CPU 资源浪费:while 循环不断判断共享变量,对 CPU 造成极大消耗
  2. 内存可见性问题:在多线程环境下,共享变量若无适当的同步机制(锁或内存屏障 Memory Barrier),可能因 CPU 缓存一致性问题导致主线程无法及时看到子线程对 eventLoop 的修改

使用 mutex + condition variable 的方式既高效又正确:主线程在 pthread_cond_wait 上休眠,不消耗 CPU 资源;子线程完成初始化后通过 pthread_cond_signal 唤醒主线程,同时 mutex 保证了共享变量的内存可见性。

内核版本注记pthread_mutexpthread_cond 是 POSIX 线程同步原语,自 POSIX.1-2001 标准化。在 Linux 上,mutex 的实现基于 futex(Fast Userspace Mutex,Linux 2.6 引入),在无竞争时完全在用户态完成加锁/解锁,仅在竞争时陷入内核态,性能开销极低。Linux 7.0 中 futex 已支持优先级继承(Priority Inheritance)等高级特性,可有效避免优先级反转(Priority Inversion)问题。

channel_map 的内存设计

channel_map 本质上是一个指针数组,数组下标与套接字描述符直接映射。虽然部分元素被浪费(如 stdin/stdout/stderr 对应的描述符 0、1、2),但总体效率极高——查找操作为 O(1)。

动态增长策略

channel_map 的内存管理采用按需增长(On-Demand Growth)策略:

  • 初始时指针数组大小为 0
  • 随着实际使用的套接字描述符增长,按 32、64、128 的速度成倍增长
  • 既满足实际需求,又避免一次性占用过多内存

增长时使用 realloc() 保留原有内容,memset() 将新申请的内存初始化为 0,兼顾效率与内存节省。

优化说明:在连接数极大(如 C10K/C10M 场景)时,channel_map 的数组长度可能达到数万,内存占用不可忽略。生产级框架(如 Nginx)使用红黑树或哈希表替代数组映射,在保持 O(1) 或 O(log n) 查找效率的同时减少内存浪费。此外,Linux 7.0 中 epoll 的 epoll_data_t.data.ptr 可直接存储 channel 指针,无需额外的映射结构。

内核版本注记:Linux 内核的文件描述符分配策略与此类似。/proc/sys/fs/file-max 控制系统级最大文件描述符数,/proc/sys/fs/file-nr 显示当前分配/使用/最大值。Linux 7.0 中默认的 file-max 值通常为数百万元级别。

总结

本文针对实战篇中的关键设计问题进行了集中解答:

  1. 发送策略:Fast Path + Slow Path 双路径设计,先尝试直接发送避免数据拷贝,不满足条件时由框架缓冲接管,兼顾效率与可靠性
  2. 两层回调:第一层面向应用程序的语义接口,第二层面向事件分发机制的 I/O 处理接口,两者通过组合实现解耦
  3. tcp_connection 与 channel:组合模式的应用,tcp_connection 提供完整的连接语义,channel 作为内部事件抽象
  4. 同步机制:mutex + condition variable 替代 busy-waiting,保证正确性与效率
  5. channel_map 内存策略:按需成倍增长,O(1) 查找,兼顾空间与时间效率

这些设计要点共同构成了高性能网络编程框架的基础,体现了事件驱动(Event-Driven)、分层抽象(Layered Abstraction)、按需分配(On-Demand Allocation)等核心设计原则。

版本信息

项目说明
更新日期2026-06-09
目标内核Linux 7.0
pthread_mutex / pthread_condPOSIX.1-2001 — Linux 基于 futex(Linux 2.6+)实现
futexLinux 2.6 — Fast Userspace Mutex,支持优先级继承
epoll_data_t.data.ptrLinux 2.6 — 可直接存储 channel 指针,避免 channel_map 查找
SO_REUSEPORTLinux 3.9 — 多线程 accept 负载均衡
TCP_CORKLinux 2.5.28 — 优化 HTTP 响应发送,减少小包传输
realloc / memsetISO C89 — 动态内存管理