70、libuv基础:libuv事件循环、TCP handle、UV库使用

说实话,很多C语言开发者一听到“事件循环”就觉得头大。我当年刚接触libuv时也有这种感觉——这东西到底在转什么?

libuv是Node.js的底层I/O引擎,后来被独立出来。它解决了跨平台异步I/O的问题。你想想看,Windows有IOCP,Linux有epoll,macOS有kqueue,每个平台的机制都不一样。libuv帮你把这些差异全抹平了。

我个人习惯把libuv理解成一个“事件调度中心”。你注册事件,它帮你盯着,事件来了就回调你。就这么简单。

核心要点: libuv的事件循环是单线程的,但非阻塞。它不会卡住你的程序。

70.1 事件循环的运行机制

libuv的事件循环用uv_run()启动。它内部维护了多个阶段(phase),每个阶段处理不同类型的事件。

我记得第一次看libuv源码时,发现它把事件循环分成了这几个阶段:

  • timers阶段:处理uv_timer_t定时器回调
  • I/O回调阶段:处理已完成的I/O操作回调
  • idle/prepare阶段:每次循环都会执行,用于准备工作
  • poll阶段:真正阻塞等待I/O事件的地方
  • check阶段:poll之后执行,常用于uv_write_t的写完成回调
  • close回调阶段:处理uv_close()的关闭回调

为什么会这样设计?说白了,就是为了保证回调的执行顺序可控。你想想看,如果定时器回调和I/O回调混在一起,程序逻辑很容易乱套。

libuv 事件循环结构图 1. timers 阶段 2. I/O 回调阶段 3. idle/prepare 4. poll(阻塞等待) 5. check 阶段 6. close 回调 循环继续,直到 uv_stop() 被调用或所有 handle 被关闭

70.2 TCP handle 的使用

libuv的TCP handle用uv_tcp_t表示。它封装了socket的创建、绑定、监听、连接、读写等操作。

我在项目中遇到过一个问题:用libuv写一个高并发TCP服务器,结果发现连接数一上去就丢包。后来排查发现,是uv_read_start的回调里处理太慢,导致内核缓冲区满了。嗯,这里要注意——回调里千万别做耗时操作。

70.2.1 创建TCP服务器

#include <uv.h>
#include <stdio.h>

uv_loop_t *loop;
uv_tcp_t server;

void on_connection(uv_stream_t *server, int status) {
    if (status < 0) {
        fprintf(stderr, "连接错误: %s\n", uv_strerror(status));
        return;
    }
    
    uv_tcp_t *client = (uv_tcp_t*)malloc(sizeof(uv_tcp_t));
    uv_tcp_init(loop, client);
    
    if (uv_accept(server, (uv_stream_t*)client) == 0) {
        // 开始读取客户端数据
        uv_read_start((uv_stream_t*)client, alloc_buffer, on_read);
    } else {
        uv_close((uv_handle_t*)client, NULL);
    }
}

int main() {
    loop = uv_default_loop();
    
    uv_tcp_init(loop, &server);
    
    struct sockaddr_in addr;
    uv_ip4_addr("0.0.0.0", 8888, &addr);
    
    uv_tcp_bind(&server, (const struct sockaddr*)&addr, 0);
    
    int r = uv_listen((uv_stream_t*)&server, 128, on_connection);
    if (r) {
        fprintf(stderr, "监听失败: %s\n", uv_strerror(r));
        return 1;
    }
    
    printf("服务器启动,监听端口 8888\n");
    uv_run(loop, UV_RUN_DEFAULT);
    
    return 0;
}
个人经验: uv_listen的第二个参数是backlog,我一般设128或256。设太大反而会降低性能,因为内核要维护更大的半连接队列。

70.2.2 创建TCP客户端

void on_connect(uv_connect_t *req, int status) {
    if (status < 0) {
        fprintf(stderr, "连接失败: %s\n", uv_strerror(status));
        return;
    }
    
    // 连接成功,开始读写
    uv_read_start((uv_stream_t*)req->handle, alloc_buffer, on_read);
    
    // 发送数据
    char *msg = "Hello from libuv!";
    uv_buf_t buf = uv_buf_init(msg, strlen(msg));
    uv_write_t *write_req = (uv_write_t*)malloc(sizeof(uv_write_t));
    uv_write(write_req, (uv_stream_t*)req->handle, &buf, 1, on_write);
}

int main() {
    loop = uv_default_loop();
    
    uv_tcp_t *socket = (uv_tcp_t*)malloc(sizeof(uv_tcp_t));
    uv_tcp_init(loop, socket);
    
    struct sockaddr_in addr;
    uv_ip4_addr("127.0.0.1", 8888, &addr);
    
    uv_connect_t *connect_req = (uv_connect_t*)malloc(sizeof(uv_connect_t));
    uv_tcp_connect(connect_req, socket, (const struct sockaddr*)&addr, on_connect);
    
    uv_run(loop, UV_RUN_DEFAULT);
    
    return 0;
}

70.3 内存管理与handle生命周期

libuv的handle有明确的生命周期。我见过不少新手犯的错误——handle用完了不关闭,或者关闭了还在用。

操作 函数 说明
初始化 uv_tcp_init() 分配并初始化handle
绑定 uv_tcp_bind() 绑定地址和端口
监听 uv_listen() 开始监听连接
接受连接 uv_accept() 接受客户端连接
读写 uv_read_start() / uv_write() 异步读写数据
关闭 uv_close() 关闭handle,释放资源
避坑指南: 我曾经在项目中忘记调用uv_close(),结果程序退出时事件循环一直不结束。因为libuv会检查所有handle是否都已关闭,没关闭的会阻止uv_run()返回。所以,用完的handle一定要关。

70.4 缓冲区管理

libuv的读写操作需要你提供缓冲区。它不会帮你管理内存,你得自己搞定。

void alloc_buffer(uv_handle_t *handle, size_t suggested_size, uv_buf_t *buf) {
    buf->base = (char*)malloc(suggested_size);
    buf->len = suggested_size;
}

void on_read(uv_stream_t *client, ssize_t nread, const uv_buf_t *buf) {
    if (nread > 0) {
        // 处理收到的数据
        printf("收到 %zd 字节: %.*s\n", nread, (int)nread, buf->base);
    } else if (nread < 0) {
        if (nread != UV_EOF) {
            fprintf(stderr, "读取错误: %s\n", uv_strerror(nread));
        }
        uv_close((uv_handle_t*)client, NULL);
    }
    
    // 记得释放缓冲区
    free(buf->base);
}

我个人习惯在alloc_buffer里用malloc,在on_read里用free。这样配对清晰,不容易内存泄漏。

70.5 错误处理

libuv的函数返回值或回调的status参数,都携带错误信息。用uv_strerror()可以转成可读的字符串。

我记得有一次线上排查问题,发现客户端连不上服务器。用uv_strerror一看,返回的是“Connection refused”。原来是服务器没启动。嗯,这种低级错误,有个好用的错误处理机制能省不少时间。

关键点: libuv的错误码都是负数。判断时用 if (status < 0),不要用 if (status == -1),因为不同错误码代表不同错误。

70.6 实战建议

  • 单线程模型:libuv的事件循环是单线程的,不要在回调里做阻塞操作。如果必须做,用uv_queue_work()丢到线程池。
  • handle复用:关闭后的handle可以用uv_tcp_init()重新初始化,但我不建议这么做。重新malloc一个更安全。
  • 调试技巧:用UV_DEBUG环境变量可以开启libuv的调试日志。我排查问题时经常用这个。
  • 性能调优:如果服务器需要处理大量短连接,考虑用uv_tcp_nodelay()禁用Nagle算法,减少延迟。

libuv其实不复杂。你把它当成一个事件驱动的框架,注册好回调,然后让事件循环跑起来就行。剩下的,libuv会帮你搞定。


公众号:蓝海资料掘金营,微信deep3321