ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

libevent事件驱动网络编程实战:从epoll封装到高并发服务

libevent事件驱动网络编程实战:从epoll封装到高并发服务 1. 为什么网络服务越写越卡libevent想解决什么问题上周有个线上服务出了幺蛾子白天还行一到晚上就卡成PPT。我翻了下代码典型的“一个连接一个线程”老式写法socket是阻塞的线程里一读就挂住。连接数一多线程上下文切换直接把CPU烧满客户端该超时的超时该重连的重连。后面我把核心IO全部换成事件驱动单线程扛几千个长连接稳稳的。折腾一圈下来最顺手的还是今天的主题libevent。1.1 从阻塞模型说起传统服务端处理网络请求最常见的套路是这样主线程accept一个连接然后创建一个新线程去处理这个连接。线程里调recv等待客户端数据有数据就处理没数据就一直睡着。听起来很自然但问题恰恰出在“睡着”上。每个线程都有独立的栈、内核调度实体线程多了以后内核要花大量时间做上下文切换。假如一个线程处理一个连接一万个连接就是一万个线程这已经能把操作系统搞崩。就算用线程池只要阻塞在recv上的线程够多调度开销也压不住。另一个办法是IO多路复用。Linux下有epoll、select、poll这一族能同时监听一堆fd哪个fd可读可写内核告诉你你再去处理。原理不复杂但直接用epoll写业务要自己维护fd状态、事件表、回调分发稍微复杂一点就全是坑。libevent就是把这层东西封装好给你一套统一的API底层用epoll还是kqueue自动切换你只需要关注“事件来了干什么”。1.2 事件驱动到底怎么回事事件驱动这个词听起来高大上说穿了很简单你告诉libevent“这个fd上有数据到了就叫我”然后程序进入一个循环等内核通知。这个循环在libevent里叫event_base_dispatch。它内部做三件事收集注册好的事件、等事件发生、触发对应的回调函数。我经常拿活页本打比方每个事件就是一张贴上标签的纸上面写着“谁fd、什么时候可读/可写/超时、叫哪个函数”。event_base就是那本活页夹event_base_dispatch就是不停翻页检查。哪张纸上写的条件满足了就把对应的回调拿出来执行。这样做的好处是不管有多少个fd等待工作都由一个线程完成回调里处理数据也很快不会像阻塞线程那样一睡不起。CPU不用在成千上万个线程之间来回切换自然省出大量时间。1.3 libevent和其他网络库怎么选有人会问既然有epoll为什么还要libevent直接用epoll不也能写吗能写但代价是你要自己处理可移植性。libevent在Linux用epoll在macOS用kqueue在Windows用IOCP同一套代码在这些平台上都能编译运行。你直接调epoll到了macOS就得重写一遍够受的。还有人会提libev、Boost.Asio。libev更轻量但功能上更偏底层网络处理的封装不如libevent完整。Boost.Asio功能强大但模板写起来重编译时间长团队如果全是C背景上手成本高。libevent用纯C写的API直白自带bufferevent、连接监听器这种高层封装很适合做网络后端、中间件、代理服务也更适合想搞懂事件驱动本质的人。2. 把环境跑通安装libevent并跑起第一个事件循环说了半天先动手把环境弄起来。我以Linux为主macOS和Windows也会提一下。2.1 安装方式说明Debian/Ubuntu系一行命令sudo apt install libevent-dev装好之后头文件在/usr/include/event2库文件是libevent、libevent_core、libevent_extra。如果只想用核心事件功能链接libevent_core就够我平时图省事直接链接libevent。macOS用Homebrewbrew install libeventWindows建议走vcpkgvcpkg install libevent如果你要最新版本或者想改编译选项就源码编译。去官网下载release包解压后执行./configure make -j$(nproc) sudo make install源码编译时注意一下目标架构32位和64位别混。装完可以用pkg-config查一下版本pkg-config --modversion libevent2.2 第一个例子空的事件循环写一个最小程序验证环境是否正常#include event2/event.h #include stdio.h int main(void) { struct event_base *base event_base_new(); if (!base) { fprintf(stderr, 创建event_base失败\n); return 1; } printf(libevent版本: %s\n, event_get_version()); event_base_dispatch(base); event_base_free(base); return 0; }编译时指定头文件和库gcc test.c -o test -levent跑一下会看到版本号。这里event_base_dispatch因为没有注册任何事件通常会直接返回所以程序正常退出。这个例子虽然简单但说明了一个关键点libevent的一切都围绕event_base展开它是事件的容器。2.3 给事件循环加点料定时器回调纯空循环没意思加一个定时器事件让循环真正跑起来#include event2/event.h #include stdio.h #include sys/time.h static void timer_cb(evutil_socket_t fd, short what, void *arg) { printf(定时器触发事件类型: %d\n, what); } int main(void) { struct event_base *base event_base_new(); if (!base) { fprintf(stderr, 创建event_base失败\n); return 1; } struct timeval tv {2, 0}; struct event *timer event_new(base, -1, EV_TIMEOUT, timer_cb, NULL); event_add(timer, tv); event_base_dispatch(base); event_free(timer); event_base_free(base); return 0; }event_new第一个参数是event_base第二个参数在这个例子里传-1因为我们不需要fd第三个参数EV_TIMEOUT表示定时器事件第四个参数是回调函数第五个是传给回调的arg。event_add会把事件挂到event_base上并指定超时时间。运行后你会看到程序等两秒打印一行然后退出。为什么退了因为这个定时器是一次性事件触发之后就变成非未决状态event_base里没有其他事件dispatch就返回了。后面我们会讲怎么让事件循环一直跑。3. 吃透三个核心对象event_base、event和注册方式想用好libevent必须把event_base、event、事件回调这几个概念揉碎了理解。3.1 event_base一切的容器event_base可以理解为一个独立的“事件处理引擎”。一个进程里可以创建多个event_base但通常每个线程最多跑一个这样不用加锁也能保证线程安全。常用函数就几个event_base_new创建引擎。event_base_dispatch进入事件循环等价于event_base_loop(..., 0)。event_base_loopexit在指定时间后退出循环。event_base_loopbreak立即退出循环但会先处理完当前正在执行的回调。event_base_free销毁引擎释放内部资源。事件循环是libevent的核心运行机制。你可以把它当成一个“大管家”注册好的事件全交给它看着哪个事件有动静它就调哪个事件的回调。回调执行完毕它继续等其他事件。3.2 event事件描述与回调event表示“在某个fd上发生了某种条件时执行某个函数”。创建事件的接口是struct event *event_new(struct event_base *base, evutil_socket_t fd, short what, event_callback_fn cb, void *arg);fd可以是普通socket、管道、定时器的-1。what是事件类型常见的有EV_READfd可读时触发。EV_WRITEfd可写时触发。EV_TIMEOUT超时触发配合event_add的timeval参数。EV_PERSIST事件触发后不自动移除继续保持pending状态类似“周期任务”。EV_ET边缘触发模式。回调函数长这样void callback(evutil_socket_t fd, short what, void *arg);fd是发生事件的描述符what是本次触发的事件类型arg是event_new里传进去的context。用arg可以避免大量全局变量比如把业务对象指针传进去。3.3 事件状态与生命周期理解libevent一定要记住事件的几种状态初始化event_new创建完还没挂到event_base上。未决pending调用了event_add等待事件触发。活跃active事件条件满足回调被调用或者正在回调队列里等待调用。如果一个事件没有加EV_PERSIST它触发一次后会自动从未决状态变成非未决。下次想让它再触发得重新event_add。如果加了EV_PERSIST触发后还会继续保持未决。与生命周期相关的函数event_add把事件注册进去。event_del把事件从event_base上摘掉。很多崩溃都是因为事件还在队列里就free了所以通常先del再free。event_free释放事件对象。我的习惯是所有event_new创建的事件在退出前先event_del再event_free顺序不要反。如果事件处于pending状态直接free轻则内存泄漏重则回调时访问已释放内存直接段错误。3.4 常用标志位和跨平台类型写libevent代码文件描述符类型建议用evutil_socket_t而不是int。Windows下socket句柄和int尺寸不一样用evutil_socket_t能保证跨平台。socket初始化时libevent提供了evutil_make_socket_nonblocking用来把fd设置成非阻塞。像accept、recv、send这些操作如果直接拿默认的阻塞fd注册给libevent会出现“明明事件说可读结果read还是把人卡住”的情况所以非阻塞必须做。我自己最多用的是EV_READ加上EV_PERSIST组合。比如监听一个socket可读就希望每次有数据都触发而不是读一次就断所以持久事件是网络程序里最常见的配置。4. 用bufferevent封装socket读写少写一半代码直接用eventrecvsend写服务端你会发现还要处理缓冲区、黏包、半包、非阻塞EWOULDBLOCK麻烦得很。libevent早就想到了bufferevent就是专门干这个的。4.1 直接监听fd和bufferevent的区别裸用event监听socket回调触发后你要自己调recv把数据读出来还要想办法保存“没读完的数据”。用buffereventlibevent内部维护了输入缓冲区和输出缓冲区数据到了它会自动帮你读到输入缓冲区你只需要在回调里从输入缓冲区拿数据就行。往客户端写数据时你只管把数据交给输出缓冲区bufferevent会自己去写如果写不完会等fd可写了再继续。这就好比以前你每天要自己去水龙头接水、端水、还要记得随时看着水有没有满。bufferevent就是给你装了个水箱和自动水管到点了有人喊你“水到了”你直接从水箱里舀就行。4.2 bufferevent常用接口创建一个基于socket的buffereventstruct bufferevent *bufferevent_socket_new(struct event_base *base, evutil_socket_t fd, int options);options常用BEV_OPT_CLOSE_ON_FREE表示bufferevent被free时自动关闭底层fd省得自己关两次。还有BEV_OPT_THREADSAFE多线程环境下可以开但要配合libevent的线程支持模块。给bufferevent设置回调void bufferevent_setcb(struct bufferevent *bev, bufferevent_data_cb readcb, bufferevent_data_cb writecb, bufferevent_event_cb eventcb, void *cbarg);readcb是读回调当输入缓冲区有新数据时被调用writecb是写回调输出缓冲区中的数据被写完一部分后调用eventcb是事件回调用来通知连接关闭、出错、超时等。还有两个必须调的函数bufferevent_enable(bev, EV_READ|EV_WRITE)开启读写能力。bufferevent_disable(bev, EV_READ)暂时关闭读能力比如背压场景。从输入缓冲区读数据size_t bufferevent_read(struct bufferevent *bev, void *data, size_t size);往输出缓冲区写数据int bufferevent_write(struct bufferevent *bev, const void *data, size_t size);bufferevent_write内部会排队哪怕一次写不完它也会记住没写完的部分等fd可写时继续写。这对非阻塞socket非常友好。4.3 数据收发与水位线bufferevent还支持水位线。所谓水位线就是读缓冲区阈值。默认低水位是0意思是只要有数据就通知读回调。如果你希望累计到一定量再让业务处理可以设置低水位bufferevent_setwatermark(bev, EV_READ, 1024, 0);这表示输入缓冲区累计超过1024字节才触发读回调。高水位设置为非0时输入缓冲区超过高水位bufferevent会停止从socket读取数据起到限流作用。这些在写解析器或流控时非常有用。4.4 完整echo server拆解用bufferevent写个回显服务代码量其实不大#include event2/event.h #include event2/listener.h #include event2/bufferevent.h #include arpa/inet.h #include stdio.h #include string.h static void read_cb(struct bufferevent *bev, void *arg) { char buf[1024]; int n; while ((n bufferevent_read(bev, buf, sizeof(buf) - 1)) 0) { buf[n] \0; printf(收到: %s\n, buf); bufferevent_write(bev, buf, n); } } static void event_cb(struct bufferevent *bev, short what, void *arg) { if (what BEV_EVENT_EOF) { printf(客户端关闭连接\n); } else if (what BEV_EVENT_ERROR) { perror(bufferevent error); } bufferevent_free(bev); } static void accept_cb(struct evconnlistener *listener, evutil_socket_t fd, struct sockaddr *addr, int socklen, void *arg) { struct event_base *base evconnlistener_get_base(listener); struct bufferevent *bev bufferevent_socket_new(base, fd, BEV_OPT_CLOSE_ON_FREE); bufferevent_setcb(bev, read_cb, NULL, event_cb, NULL); bufferevent_enable(bev, EV_READ | EV_WRITE); } int main(void) { struct event_base *base event_base_new(); struct sockaddr_in sin; memset(sin, 0, sizeof(sin)); sin.sin_family AF_INET; sin.sin_port htons(9999); struct evconnlistener *listener evconnlistener_new_bind( base, accept_cb, NULL, LEV_OPT_REUSEABLE | LEV_OPT_CLOSE_ON_FREE, 128, (struct sockaddr *)sin, sizeof(sin)); if (!listener) { perror(evconnlistener_new_bind); return 1; } printf(echo server 监听 9999 端口\n); event_base_dispatch(base); evconnlistener_free(listener); event_base_free(base); return 0; }编译时加上-levent。启动后可以用任意TCP客户端连9999端口发什么回什么。这个例子把accept、读写、断连全串起来了是理解libevent服务端比较合适的起点。5. 定时器、信号与连接监听服务端刚需配套除了网络读写服务端还有几个绕不开的需求定时任务、优雅退出、并发模型。5.1 定时器到底怎么用定时器在libevent里也是一个eventfd传-1事件类型传EV_TIMEOUT。前面我们写过一次性定时器。如果希望周期性执行就给event_new加上EV_PERSISTstruct event *timer event_new(base, -1, EV_TIMEOUT | EV_PERSIST, timer_cb, arg); struct timeval interval {5, 0}; event_add(timer, interval);这样每5秒就会触发一次timer_cb适合做心跳检查、定期清理、指标上报。还有一个相对较新的定时器接口event_timer_new用法类似但底层实现会用到更精确的时间堆。不过基础场景event_new完全够用。需要注意定时器回调里钻牛角尖做大量计算一样会阻塞事件循环。定时器只是帮你“到点提醒”并不是独立的线程。想在到点时做耗时的清理最好把任务丢到线程池。5.2 信号事件与优雅退出线上服务最怕kill -9来不及清理连接和文件就没了。通常我们希望收到SIGINT或SIGTERM时先停止接受新连接再把已有连接慢慢关掉最后退出进程。libevent支持信号事件static void signal_cb(evutil_socket_t sig, short events, void *arg) { struct event_base *base (struct event_base *)arg; printf(收到信号 %d正在退出\n, sig); event_base_loopbreak(base); } struct event *sigint evsignal_new(base, SIGINT, signal_cb, base); evsignal_add(sigint, NULL);evsignal_new是event_new对信号事件的封装。注册之后按CtrlC进程就会走安全退出流程。注意信号处理这个语境下回调里尽量只做标志位修改或event_base_loopbreak不要干重活。5.3 evconnlistener管理连接上面echo server里已经出现了evconnlistener。它是libevent对“监听socket”的封装省得你手动socket、bind、listen、accept。创建接口struct evconnlistener *evconnlistener_new_bind( struct event_base *base, evconnlistener_cb cb, void *ptr, unsigned flags, int backlog, const struct sockaddr *sa, int socklen);回调里会拿到已连接的fd你直接在回调里创建bufferevent处理这个fd。常用flagLEV_OPT_REUSEABLESO_REUSEADDR端口快速复用。LEV_OPT_CLOSE_ON_FREElistener释放时关闭底层监听fd。LEV_OPT_REUSEABLE_PORT负载均衡场景下允许内核把连接分发到多进程Linux下有用。有了evconnlistener不需要手动维护listen fd的可读事件accept的逻辑已经由libevent内部接管整个服务端骨架就清晰很多。5.4 多线程模型的切入点libevent默认不是线程安全的但实战中单线程处理不了所有场景比如一个回调里要做耗时计算或者要连接数据库。这时几种常见打法每个线程一个event_base主线程接收连接把fd分发给工作线程的event_base处理。这是最推荐的做法各线程之间互不干扰。多线程共享同一个event_base然后用evthread_use_pthreads开启线程支持再通过bufferevent的BEV_OPT_THREADSAFE保证内部锁安全。这个方案理解起来容易但锁竞争会影响性能如果连接数量非常大不推荐。事件循环线程池混合模式网络IO走libevent耗时的业务逻辑丢给线程池结果再通过内部管道通知事件循环写回客户端。我用这个方案比较多。不管哪种模型都要记住一点不要在事件回调里直接阻塞等待一个远程操作结果比如同步调数据库、同步调外部HTTP接口否则整个事件循环都会卡住。6. 踩坑记录libevent用久了我总结的几条铁律这部分是我自己实际项目里掉过的坑拿出来说透。6.1 回调里千万别做阻塞操作第一次用libevent写服务我犯过一个低级错误在read_cb里调用了带超时阻塞10秒的第三方认证接口。结果一个连接把整个事件循环卡住10秒其他所有连接集体超时。这比阻塞模型还糟糕。事件回调执行的时间决定了整个event_base里所有事件的响应速度。回调里只做内存级别的处理、解析、转发数据。耗时任务必须异步化要么切线程池要么把结果用eventfd或管道通知回来让另一个事件回调继续处理。6.2 事件未删就free崩溃来得莫名其妙libevent的event在pending状态下内部事件队列还存着它的指针。如果你直接event_free队列里就会残留悬空指针。等事件条件一满足libevent尝试访问已经释放的内存段错误甚至内存数据被改坏排查起来非常痛苦。所以释放顺序是先event_del再event_free。同理bufferevent如果设置了BEV_OPT_CLOSE_ON_FREE那么bufferevent_free会关闭fd你不需要再单独close否则可能出现文件描述符被意外重用后又被多关一次的诡异问题。如果是通过evconnlistener回调创建的bufferevent在event_cb里收到BEV_EVENT_EOF或BEV_EVENT_ERROR时处理完业务数据后及时bufferevent_free以免连接对象堆积。6.3 线程模型要提前想清楚libevent默认不是线程安全的。如果你在多个线程里调用同一个event_base的event_add、event_del、bufferevent_write要先evthread_use_pthreads确保内部锁被编译出来。但即使开了线程支持也不要让多个线程同时跑event_base_dispatch这会导致同一个fd被重复处理。我见过一个项目主业务线程和主线程共同调度同一个event_base平时没事一压测就随机崩溃。后来改成“一个连接只属于一个线程的event_base”问题立刻消失。基于现在的CPU规模多事件循环加连接分片是更稳的路线。6.4 持久事件、一次性事件和自动释放不带EV_PERSIST的事件在触发一次后会从pending状态移除但不会自动释放event对象。很多人以为触发完就“完事了”结果多次触发导致event_add重复叠加内存泄漏和事件重复触发一起出现。如果你一个event需要周期性读fd记得用EV_PERSIST。如果你像某类需求只想读一次那就在回调里手动event_del并free但要注意回调退出后再free不要在回调正执行的时候立刻操作event避免状态不一致。我自己会把“一次性事件”包装成一个结构体回调最后一个动作是event_free不容易漏。6.5 连接关闭的几种姿势服务端处理断连常见有三种情况客户端正常关闭、客户端直接RST、服务端主动关闭。在event_cb里BEV_EVENT_EOF 表示读取到EOF客户端正常关闭此时输入缓冲区可能还有没处理完的数据先处理完再free。BEV_EVENT_ERROR 表示底层错误打印perror或者日志然后清理。BEV_EVENT_TIMEOUT 表示读写超时可能是心跳过期按你的业务逻辑决定是断开还是继续保留。如果是要主动断开调用bufferevent_free之前确认输出缓冲区里的数据已经写完或者调用bufferevent_flush先刷一下。BEV_OPT_CLOSE_ON_FREE会在free时关fd所以不要有别的代码再close同一个fd。还有一个细节非阻塞socket大量连接时注意系统fd上限Linux下用ulimit -n看压测前记得提高。libevent能处理的连接数最终还是受限于操作系统的进程文件描述符上限这个坑和libevent本身无关但很多人定位不到。最后再分享一个调试习惯为了确认一个事件到底有没有被反复触发我会在回调开头加一个debug计数器定期打印。事件驱动编程的调试方式和同步阻塞模型差异很大不能用“打断点一步步走”的思维去调并发问题。用日志、计数、状态机视角去看会顺手很多。
返回列表