Linux Socket 编程与 epoll 入门:从 0 到最小可运行 Demo,再到海量连接框架
这篇文章的目标很明确:
- 帮你搞懂 Linux 下最基础的 socket 编程流程。
- 帮你搞懂
epoll到底是什么、为什么它适合高并发。 - 逐个介绍常见函数的作用和用法。
- 给出一个最小可运行的
socketdemo。 - 给出一个最小可运行的
epolldemo。 - 最后给出一个适合“异步通信 / 海量连接”的服务器框架伪代码。
如果你以前总听别人说:
epoll 很重要非阻塞 + epoll 才能处理海量连接Boost.Asio 底层在 Linux 上也离不开 epoll
但自己没真正写过,那这篇文章就是给你补这个坑的。
一、先建立整体图景:Linux 下 TCP 服务器到底在做什么
一个最基础的 TCP 服务端,流程其实很固定:
- 创建一个监听 socket
- 绑定 IP 和端口
- 开始监听
- 接收客户端连接
- 对每个连接做收发数据
- 关闭连接
对应到 Linux socket API,通常就是:
socket():创建套接字bind():绑定地址和端口listen():把 socket 变成监听状态accept():接收客户端连接recv()/read():读数据send()/write():写数据close():关闭 fd
所以 socket 编程的本质并不神秘:
就是把网络连接当成一个“可读可写的文件描述符 fd”去操作。
二、什么是 socket
可以先把 socket 理解成:
操作系统为网络通信提供的一个端点。
在 Linux 里,一切皆文件。socket 本质上也会对应一个文件描述符 fd。
例如:
1 | int listenfd = socket(AF_INET, SOCK_STREAM, 0); |
这里:
AF_INET:表示 IPv4SOCK_STREAM:表示 TCP(流式协议)- 返回值
listenfd:就是这个 socket 对应的文件描述符
后面你对这个 fd 做 bind、listen、accept、recv、send,就完成了网络通信。
三、最基础的 TCP 服务端,每个函数是干什么的
1. socket()
作用
创建一个 socket,返回文件描述符。
原型
1 | int socket(int domain, int type, int protocol); |
常见参数
- domain
AF_INET:IPv4AF_INET6:IPv6
- type
SOCK_STREAM:TCPSOCK_DGRAM:UDP
- protocol
- 通常填
0,让系统根据前两个参数自动选择
- 通常填
示例
1 | int listenfd = socket(AF_INET, SOCK_STREAM, 0); |
注意
如果返回值 < 0,说明创建失败。
2. bind()
作用
把这个 socket 和某个 IP、端口绑定起来。
原型
1 | int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
为什么要 bind
因为服务端必须明确:
我到底监听哪一个 IP、哪一个端口?
示例
1 | sockaddr_in addr; |
解释
sin_family = AF_INET:IPv4sin_port = htons(8888):监听 8888 端口INADDR_ANY:监听本机所有网卡 IP
为什么要 htons / htonl
因为网络字节序通常使用大端序,而主机字节序可能不同,所以需要转换。
htons:host to network shorthtonl:host to network long
3. listen()
作用
把 socket 变成一个“监听 socket”。
原型
1 | int listen(int sockfd, int backlog); |
示例
1 | listen(listenfd, 128); |
解释
backlog 表示监听队列长度的大致上限。
它的直觉可以理解成:
当前一批新连接还没来得及
accept,它们先在队列里排队。
4. accept()
作用
从监听 socket 上接收一个新的客户端连接。
原型
1 | int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); |
示例
1 | sockaddr_in client_addr; |
解释
listenfd:监听 socket- 返回的
connfd:和某个具体客户端通信的新连接 fd
这里要注意:
监听 socket 和连接 socket 不是同一个东西。
listenfd:只负责接连接connfd:只负责和某个客户端收发数据
5. recv()
作用
从连接 socket 读取数据。
原型
1 | ssize_t recv(int sockfd, void *buf, size_t len, int flags); |
示例
1 | char buf[1024]; |
返回值含义
n > 0:读到了n字节n == 0:对端关闭连接n < 0:发生错误
6. send()
作用
向连接 socket 发送数据。
原型
1 | ssize_t send(int sockfd, const void *buf, size_t len, int flags); |
示例
1 | const char* msg = "hello client\n"; |
7. close()
作用
关闭文件描述符。
示例
1 | close(connfd); |
四、最简单的 Linux TCP 服务端 Demo
这个版本不引入 epoll,只是帮你先熟悉 socket 基础流程。
1 | #include <iostream> |
1 |
|
这个 demo 的缺点
这个 demo 只能处理:
- 一个客户端
- 一次收发
- 同步阻塞
它的逻辑非常简单,但也说明一个问题:
如果连接很多,这种写法完全不够。
因为:
accept()可能卡住recv()可能卡住- 一个客户端没发数据,线程就被挂住了
- 根本不适合海量长连接
这就引出 epoll。
五、什么是 epoll
你可以先用一句最通俗的话理解:
epoll 是 Linux 下用于同时管理大量 fd 就绪事件的一种机制。
它的目的就是解决:
我有很多连接,不能一个连接一个线程去等,那我怎么用少量线程同时盯着这些连接?
答案就是:
- 把一批 fd 注册到 epoll
- 告诉内核:我关心这些 fd 的可读/可写/异常事件
- 然后通过
epoll_wait()等待 - 一旦有事件发生,内核把“已经就绪”的 fd 返回给我
重点是:
epoll 只告诉你哪些 fd 已经有事了,不需要你每次扫描全部连接。
这就是它适合高并发的关键。
六、为什么 epoll 比 select / poll 更适合海量连接
这块是面试高频。
1. select 的问题
- fd 数量上限受限
- 每次都要把整个 fd 集合传给内核
- 返回后还要遍历所有 fd,看谁就绪
2. poll 的问题
- 没有固定上限
- 但本质上仍然要遍历整个 fd 数组
3. epoll 的优势
- fd 集合注册一次即可
epoll_wait返回的是已经就绪的事件- 不需要每次扫描所有连接
一句话记忆:
epoll 的关键优势是“只关注活跃连接,不遍历所有连接”。
七、epoll 的三个核心函数
1. epoll_create1()
作用
创建一个 epoll 实例。
原型
1 | int epoll_create1(int flags); |
示例
1 | int epfd = epoll_create1(0); |
返回值 epfd 就是 epoll 实例对应的 fd。
2. epoll_ctl()
作用
向 epoll 中添加、修改、删除要监听的 fd。
原型
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
常见操作
EPOLL_CTL_ADD:添加 fdEPOLL_CTL_MOD:修改 fd 关注的事件EPOLL_CTL_DEL:删除 fd
epoll_event 结构体
1 | struct epoll_event { |
常见事件
EPOLLIN:可读EPOLLOUT:可写EPOLLERR:错误EPOLLHUP:挂断EPOLLET:边沿触发(ET)
示例:注册一个监听 fd
1 | epoll_event ev; |
3. epoll_wait()
作用
等待事件发生,并把就绪事件返回给你。
原型
1 | int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); |
参数解释
epfd:epoll 实例events:输出数组,用于接收已就绪事件maxevents:本次最多返回多少个事件1
timeout
-1:一直等0:立即返回>0:最多等多少毫秒
示例
1 | epoll_event events[1024]; |
返回值:
n > 0:有n个事件就绪n == 0:超时n < 0:错误
八、epoll 的工作流程
完整流程可以这样理解:
第一步:创建 epoll 实例
1 | int epfd = epoll_create1(0); |
第二步:把你关心的 fd 注册进去
1 | epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev); |
第三步:循环等待事件
1 | while (true) { |
这就是典型事件循环。
九、epoll 最小可运行 Demo:回显服务器
这个 demo 很重要,因为它是:
- Linux socket
- epoll
- 非阻塞思想
- 多连接处理
最小能连起来的版本。
功能
- 监听 8888 端口
- 支持多个客户端连接
- 客户端发什么,服务端回什么
代码
1 | #include <iostream> |
编译方式
1 | g++ -std=c++11 -O2 epoll_echo_server.cpp -o server |
运行:
1 | ./server |
客户端测试可以用:
1 | telnet 127.0.0.1 8888 |
或者:
1 | nc 127.0.0.1 8888 |
十、这个 epoll demo 里每一步到底在干什么
1. epoll_create1()
创建 epoll 实例。
1 | int epfd = epoll_create1(0); |
你可以把它理解成:
创建了一个“事件管理器”。
2. epoll_ctl(ADD)
把监听 socket 注册进去。
1 | ev.events = EPOLLIN; |
意思是:
我关心
listenfd的读事件。
一旦它可读,就通知我。
为什么监听 socket 可读表示有新连接?
因为对监听 socket 来说,“可读”意味着:
当前 accept 队列里有连接可以取了。
3. epoll_wait()
等待事件发生。
1 | int n = epoll_wait(epfd, events, 1024, -1); |
意思是:
你先睡着,等有事件了再叫醒我。
醒来后告诉我,这次有哪些 fd 有事。
4. 处理监听 fd
如果返回的 fd 是 listenfd,说明来了新连接。
1 | if (fd == listenfd) { |
这里拿到的 connfd 才是真正和客户端收发数据的 fd。
5. 把新连接也加入 epoll
1 | client_ev.events = EPOLLIN; |
意思是:
以后这个客户端连接有数据可读时,也通知我。
6. 处理普通连接读事件
1 | ssize_t cnt = recv(fd, buf, sizeof(buf) - 1, 0); |
如果 cnt > 0:
- 说明读到了数据
如果 cnt == 0:
- 说明客户端关闭连接
如果 cnt < 0:
- 说明出错
十一、为什么高并发场景一定要非阻塞
刚才那个 demo 虽然已经用了 epoll,但要注意:
真正做高并发,必须让连接 socket 配合非阻塞模式。
因为在一个事件循环里,一个线程通常要负责很多连接。
如果某个连接上的一次 recv() 或 send() 阻塞住了,那这个线程就不能继续处理别的连接事件了。
所以高并发事件驱动模型通常都是:
epoll- 非阻塞 socket
- 收到事件后循环读/写到
EAGAIN
十二、非阻塞怎么设置
用 fcntl:
1 | int set_nonblocking(int fd) { |
这段代码做的事是:
- 先获取当前 fd 的标志位
- 加上
O_NONBLOCK - 再设置回去
十三、非阻塞模式下 recv() / send() 的特点
如果没有数据可读,recv() 不会一直等,而会返回:
-1- 同时
errno == EAGAIN或EWOULDBLOCK
这表示:
当前没有更多数据了,不是致命错误。
同理,send() 也可能因为发送缓冲区满了而返回 EAGAIN。
所以真正高并发代码里,你不能把 EAGAIN 当错误直接关连接。
十四、LT 和 ET 是什么
epoll 有两种常见触发模式。
1. LT:Level Trigger,水平触发
只要 fd 还处于可读/可写状态,epoll_wait 下次还会继续通知你。
优点:
- 更稳
- 更容易写
- 不容易漏事件
缺点:
- 可能会重复提醒
2. ET:Edge Trigger,边沿触发
只有状态发生变化时才通知一次。
比如:
- 从不可读变成可读,通知一次
- 如果你这次没把数据读完,后面可能不再提醒
优点:
- 减少重复通知
- 潜在性能更高
缺点:
- 更难写
- 通常必须配合非阻塞
- 一次事件里要尽量读到
EAGAIN
面试时怎么答选 LT 还是 ET
比较稳妥的回答是:
如果先实现一个稳定版本,我会优先 LT,因为逻辑更简单、不容易漏读漏写;
如果对代码严谨性和吞吐要求更高,也可以使用 ET,但要确保每次事件都把内核缓冲区尽可能处理干净。
十五、真正“异步通信、处理海量连接”的框架该怎么设计
这里给你一个更工程化的思路。
注意,Linux 下常说的“异步通信”,很多时候工程里其实是:
非阻塞 socket + epoll + 事件驱动 + 连接状态机
也就是更接近 Reactor 模型。
核心设计思路
1. 一个监听 socket
负责接入新连接。
2. 一个 epoll 实例
统一监听:
- 监听 fd
- 所有连接 fd
- 定时器 fd(如果有)
3. 一个连接对象 Connection
每个客户端连接都维护自己的上下文。
例如:
1 | struct Connection { |
4. 读事件处理
- 循环
recv - 读到数据就追加到
inBuffer - 尝试按协议解析完整消息
- 如果解析出完整消息,就投递给业务层处理
5. 写事件处理
- 如果
outBuffer有数据,循环send - 发出去多少就移除多少
- 直到发完或遇到
EAGAIN
6. I/O 和业务解耦
- I/O 线程只做网络收发和缓冲区管理
- 重业务逻辑交给工作线程池
- 业务线程把响应结果再投递回 I/O 线程发送
十六、海量连接服务器伪代码框架
下面这段是最值得你背的。
1 | create listenfd |
十七、这个框架里最关键的工程点
1. 为什么要输入缓冲区 inBuffer
因为 TCP 是字节流。
一次 recv():
- 可能只读到半个包
- 可能读到一个半包
- 也可能一次读到多个包
所以你不能假设“一次 recv 就是一个完整业务消息”。
必须把数据先放进输入缓冲区,再按协议拆包。
2. 为什么要输出缓冲区 outBuffer
因为一次 send() 也不保证把所有数据都写完。
尤其在非阻塞模式下:
- 可能只发出去一部分
- 可能暂时发不动,返回
EAGAIN
所以剩余没发完的数据必须留在输出缓冲区里,等下次可写事件继续发。
3. 为什么 EPOLLOUT 不应该一直监听
因为 socket 大多数时候通常都是可写的。
如果你一直监听 EPOLLOUT,会造成很多无意义唤醒。
正确做法通常是:
outBuffer为空时:只监听EPOLLINoutBuffer从空变非空时:加上EPOLLOUToutBuffer发空后:再去掉EPOLLOUT
4. 为什么 I/O 线程不能做重业务
因为事件循环线程的职责是:
快速处理网络事件,不要被某一个请求拖慢。
如果你在 I/O 线程里直接做复杂业务计算、数据库查询、磁盘 I/O,那么:
- 一个连接的慢处理
- 就可能拖住整个事件循环
- 影响其他连接的响应
所以常见做法是:
- I/O 线程只负责收包、发包、连接状态
- 业务逻辑丢给线程池
十八、你可以怎么向面试官总结
如果面试官问你:
epoll 怎么用?
Linux 下怎么做海量 TCP 连接?
你可以这样答:
epoll本质上是 Linux 下管理大量 fd 就绪事件的机制。
使用上通常分三步:先用epoll_create1创建 epoll 实例;再通过epoll_ctl把监听 socket 和连接 socket 注册进去,并指定关心的事件,比如EPOLLIN、EPOLLOUT;然后在主循环里调用epoll_wait等待就绪事件。真正做高并发时,我会把所有 socket 都设成非阻塞,收到读事件后循环
recv到EAGAIN,收到写事件后循环send到EAGAIN。每个连接维护自己的输入输出缓冲区,用来解决 TCP 粘包拆包和部分发送问题。再往上会把网络 I/O 和业务线程池解耦,I/O 线程只负责事件循环和缓冲区管理,业务线程负责实际逻辑处理。这样就可以实现一个 Reactor 风格的高并发 TCP 服务器,这也是 Linux 下处理海量长连接的常见方式。
十九、学习建议:你下一步该怎么练
如果你是第一次接触,建议按下面顺序练:
第 1 步
先把最简单的阻塞版 socket 服务端写出来:
socketbindlistenacceptrecvsend
第 2 步
写一个最简单的多客户端 epoll 回显服务器。
第 3 步
把连接 socket 改成非阻塞,并正确处理 EAGAIN。
第 4 步
给每个连接加输入缓冲区和输出缓冲区。
第 5 步
实现一个简单协议,比如:
- 固定长度头
- 头里带 body 长度
- 再按长度收 body
第 6 步
最后再考虑:
- 业务线程池
- 心跳超时
- 定时器
- 主从 Reactor
- 连接对象生命周期管理
这样你再回头看 Boost.Asio,就会明白:
它不是“凭空会异步”,而是把 Linux 下这些底层机制帮你统一封装好了。
二十、最后总结
整篇内容可以浓缩成下面几句话:
- socket 编程的本质,就是把网络连接当成 fd 去做读写。
- 最基础的 TCP 服务端流程是:socket -> bind -> listen -> accept -> recv/send -> close。
- epoll 是 Linux 下管理大量 fd 就绪事件的机制,特别适合高并发长连接。
- epoll 的核心 API 只有三个:
epoll_create1、epoll_ctl、epoll_wait。 - 高并发场景一定要配合非阻塞 socket。
- 海量连接服务器的关键是:事件循环、连接对象、输入缓冲区、输出缓冲区、I/O 与业务解耦。
- Linux 下所谓“异步通信”在工程里通常就是 Reactor 风格:非阻塞 + epoll + 事件驱动。
如果你真正把这篇文章吃透,再去看 Asio、muduo、nginx、redis 的网络模型,就不会再觉得那么抽象了。