Linux有个基本概念 一切皆为文件 即使不是“文件”也能以文件的形式来管理。
硬件设备、进程、网络连接等都抽象成文件,使用统一的接口,虽然文件类型各不相同,但是对外提供的都是同一套操作。
Linux 有 7 种类型的文件,分为 3 大类:
-
普通文件,包括文本文件和二进制文件
-
目录
-
特殊文件
- 链接文件
- 字符设备文件
- 套接字(Socket)文件,用于网络通讯,一般由应用程序创建
- 命名管道文件,一种特殊文件,用于进程间通信
- 块设备
IO多路复用
- select
- poll
- epoll
上面三个都属于linux的io多路复用,它们都有一个共同点 管理文件描述符 。
告诉内核想对每个文件描述符做什么(读、写、..)并使用一个线程进行阻塞调用,直到至少一个文件描述符准备就绪。
本文将使用Golang 来实现多驱动、异步、非阻塞TCP Server,代码量非常小,实现起来很简单。感兴趣的话,一起往下看吧。先来介绍一下大致实现思路。
- 由于需要多驱动,所以需要将驱动抽象为
Poller,对外提供统一的操作。 - 基于select、poll、epoll分别完成
Poller的实现。 - 实现异步非阻塞
TCP Server,可无缝切换事件驱动,而无需改造Server层。
Poller定义
尽管有多个驱动,但为了让Server在使用驱动上有统一的行为,所以我们需要定义接口以及事件。
drive/contract.go
package drive
type Opcode string
const (
OpcodeRead Opcode = "read"
OpcodeWrite Opcode = "write"
)
type ExternalEvent struct {
Fd int
Opcode Opcode
}
type Poller interface {
AddRead(fd int) error
AddWrite(fd int) error
ModRead(fd int) error
ModWrite(fd int) error
Remove(fd int) error
Polling() ([]ExternalEvent, error)
}
Select实现
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
drive/select.go
package drive
import (
"syscall"
)
type Select struct {
read syscall.FdSet
write syscall.FdSet
max int
fds map[int]int
}
func NewSelect() Poller {
return &Select{
read: syscall.FdSet{},
write: syscall.FdSet{},
max: 0,
fds: make(map[int]int),
}
}
func (s *Select) AddRead(fd int) error {
s.read.Bits[fd/64] |= 1 << (fd % 64)
if fd > s.max {
s.max = fd
}
s.fds[fd] = fd
return nil
}
func (s *Select) AddWrite(fd int) error {
s.write.Bits[fd/64] |= 1 << (fd % 64)
if fd > s.max {
s.max = fd
}
s.fds[fd] = fd
return nil
}
func (s *Select) ModRead(fd int) error {
// 删除写
s.write.Bits[fd/64] &^= 1 << (fd % 64)
return s.AddRead(fd)
}
func (s *Select) ModWrite(fd int) error {
// 删除读
s.read.Bits[fd/64] &^= 1 << (fd % 64)
return s.AddWrite(fd)
}
func (s *Select) Remove(fd int) error {
s.read.Bits[fd/64] &^= 1 << (fd % 64)
s.write.Bits[fd/64] &^= 1 << (fd % 64)
return nil
}
// Polling .
func (s *Select) Polling() ([]ExternalEvent, error) {
read := s.read
write := s.write
_, err := syscall.Select(s.max+1, &read, &write, nil, nil)
if err != nil {
return nil, err
}
external := make([]ExternalEvent, 0)
for _, fd := range s.fds {
// 可读
if s.isFdSet(read, fd) {
external = append(external, ExternalEvent{
Fd: fd,
Opcode: OpcodeRead,
})
}
// 可写
if s.isFdSet(write, fd) {
external = append(external, ExternalEvent{
Fd: fd,
Opcode: OpcodeWrite,
})
}
}
return external, nil
}
func (s *Select) isFdSet(fdSet syscall.FdSet, fd int) bool {
return (fdSet.Bits[fd/64] & (1 << (fd % 64))) != 0
}
文件描述符的限制
使用位图(bitmap)fd_set存储文件描述符, 通常受操作系统的 FD_SETSIZE 限制。在大多数 linux 系统中,FD_SETSIZE 默认是 1024。这意味着同时监控的文件描述符数量不能超过这个限制。
在高并发应用程序中(如处理大量客户端连接的服务器),这个限制会成为瓶颈。但并非一无是处,只是不符合现代应用的场景,属于时代的产物。
性能问题
随着监控的文件描述符数量增加而下降。因为每次调用时都需要检查整个fd_set集合,即使只有少数文件描述符发生了变化。
对于数百或数千个文件描述符,大量无效的检查会浪费 CPU 时间。
状态重置
在调用 select 返回数据后,文件描述符集合会被修改,需要在下一次调用前重新初始化。
Poll实现
接着来完成poll驱动,poll相比select只是fd存储结构上发生了变化,fd没有数量限制。
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
drive/poll.go
package drive
import (
"errors"
"golang.org/x/sys/unix"
"sync"
)
type Poll struct {
mu sync.Mutex // 锁
fds []unix.PollFd
fdMap map[int]int // 用于快速查找文件描述符在 fds 中的索引
}
func NewPoll() Poller {
return &Poll{
fds: make([]unix.PollFd, 0),
fdMap: make(map[int]int),
mu: sync.Mutex{},
}
}
func (p *Poll) AddRead(fd int) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, exists := p.fdMap[fd]; exists {
return errors.New("file descriptor already exists")
}
p.fds = append(p.fds, unix.PollFd{
Fd: int32(fd),
Events: unix.POLLIN,
})
p.fdMap[fd] = len(p.fds) - 1
return nil
}
func (p *Poll) AddWrite(fd int) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, exists := p.fdMap[fd]; exists {
return errors.New("file descriptor already exists")
}
p.fds = append(p.fds, unix.PollFd{
Fd: int32(fd),
Events: unix.POLLOUT,
})
p.fdMap[fd] = len(p.fds) - 1
return nil
}
func (p *Poll) ModRead(fd int) error {
p.mu.Lock()
defer p.mu.Unlock()
index, exists := p.fdMap[fd]
if !exists {
return errors.New("file descriptor does not exist")
}
p.fds[index].Events = unix.POLLIN
return nil
}
func (p *Poll) ModWrite(fd int) error {
p.mu.Lock()
defer p.mu.Unlock()
index, exists := p.fdMap[fd]
if !exists {
return errors.New("file descriptor does not exist")
}
p.fds[index].Events = unix.POLLOUT
return nil
}
func (p *Poll) Remove(fd int) error {
p.mu.Lock()
defer p.mu.Unlock()
index, exists := p.fdMap[fd]
if !exists {
return errors.New("file descriptor does not exist")
}
// 移除 fd
p.fds = append(p.fds[:index], p.fds[index+1:]...)
delete(p.fdMap, fd)
// 更新 fdMap 的索引
for i := index; i < len(p.fds); i++ {
p.fdMap[int(p.fds[i].Fd)] = i
}
return nil
}
// Polling .
func (p *Poll) Polling() ([]ExternalEvent, error) {
n, err := unix.Poll(p.fds, -1)
if err != nil {
return nil, err
}
external := make([]ExternalEvent, 0, n)
for _, fd := range p.fds {
// 可读
if fd.Revents&unix.POLLIN != 0 {
external = append(external, ExternalEvent{
Fd: int(fd.Fd),
Opcode: OpcodeRead,
})
}
// 可写
if fd.Revents&unix.POLLOUT != 0 {
external = append(external, ExternalEvent{
Fd: int(fd.Fd),
Opcode: OpcodeWrite,
})
}
}
return external, nil
}
Poll vs Select
不需要计算最高的文件描述符+1的值。
使用一个数组(pollfd 结构体)存储文件描述符,没有数量限制。
在 pollfd 结构体的 revents 字段中记录就绪事件,原始数组未被破坏,避免了 select 每次调用前重新初始化的问题。
仅遍历实际监控的文件描述符数组,但仍是线性复杂度。
支持更多的事件类型,例如:
POLLERR错误条件。POLLHUP挂起。POLLNVAL非法请求。POLLIN可读POLLOUT可写。POLLIN|POLLOUT组合事件。
EPoll
终于到epoll了,实现起来其实非常简单。
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
drive/epoll.go
package drive
import (
"log"
"syscall"
)
type EPoll struct {
fd int
events []syscall.EpollEvent
}
func NewEPoll() Poller {
fd, err := syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
if err != nil {
log.Panicln(err)
}
return &EPoll{
fd: fd,
events: make([]syscall.EpollEvent, 128),
}
}
// Polling .
func (e *EPoll) Polling() ([]ExternalEvent, error) {
n, err := syscall.EpollWait(e.fd, e.events, -1)
if err != nil {
return nil, err
}
external := make([]ExternalEvent, 0)
for i := 0; i < n; i++ {
event := e.events[i]
opcode := OpcodeRead
// 可写
if event.Events&syscall.EPOLLOUT == syscall.EPOLLOUT {
opcode = OpcodeWrite
}
external = append(external, ExternalEvent{
Fd: int(event.Fd),
Opcode: opcode,
})
}
return external, nil
}
func (e *EPoll) AddRead(fd int) error {
return syscall.EpollCtl(e.fd, syscall.EPOLL_CTL_ADD, fd, &syscall.EpollEvent{
Fd: int32(fd),
Events: syscall.EPOLLIN | syscall.EPOLLPRI,
})
}
func (e *EPoll) AddWrite(fd int) error {
return syscall.EpollCtl(e.fd, syscall.EPOLL_CTL_ADD, fd, &syscall.EpollEvent{
Events: syscall.EPOLLOUT,
Fd: int32(fd),
})
}
func (e *EPoll) ModRead(fd int) error {
return syscall.EpollCtl(e.fd, syscall.EPOLL_CTL_MOD, fd, &syscall.EpollEvent{
Events: syscall.EPOLLIN | syscall.EPOLLPRI,
Fd: int32(fd),
})
}
func (e *EPoll) ModWrite(fd int) error {
return syscall.EpollCtl(e.fd, syscall.EPOLL_CTL_MOD, fd, &syscall.EpollEvent{
Events: syscall.EPOLLOUT,
Fd: int32(fd),
})
}
// Remove .
func (e *EPoll) Remove(fd int) error {
return syscall.EpollCtl(e.fd, syscall.EPOLL_CTL_DEL, fd, nil)
}
EPoll vs Poll
有三个核心接口
- epoll_create1 创建 epoll 实例。
- epoll_ctl注册、修改或删除事件。
- epoll_wait 等待并处理就绪事件。
使用内核事件通知机制,文件描述符只需注册一次到内核态,随后只处理就绪事件。
内核使用红黑树管理,监听和触发的性能接近 O(1) (与事件数量无关,仅与活跃事件数量相关)。
支持 边缘触发 (ET) 和 水平触发(LT) 模式,更适合高性能应用场景。
- 水平触发**:**只要事件未处理会反复通知。
- 边缘触发:高效模式,事件只通知一次。
由于事件触发机制,适合大规模并发和异步任务,也是现代应用首选的io多路复用解决方案。
Server实现
Server的实现很简单,创建socket、bind端口、listen端口,accept请求。都是常规操作。
server.go
package main
import (
"bytes"
"fmt"
"io"
"io-multiplexing/drive"
"log"
"os"
"syscall"
)
type OnConnect func(client *Client)
type OnMessage func(client *Client, message []byte, n int)
type OnClose func(client *Client)
type Server struct {
fd int
epfd int
poller drive.Poller
onConnect OnConnect
onMessage OnMessage
onClose OnClose
}
func NewServer(poller drive.Poller, port int) *Server {
fd, err := syscall.Socket(
syscall.AF_INET,
syscall.SOCK_STREAM|syscall.SOCK_CLOEXEC,
syscall.IPPROTO_TCP,
)
if err != nil {
log.Panicln(err)
}
addr := syscall.SockaddrInet4{
Addr: [4]byte{0, 0, 0, 0},
Port: port,
}
if err := syscall.Bind(fd, &addr); err != nil {
log.Panicln(err)
}
// 非阻塞
if err := syscall.SetNonblock(fd, true); err != nil {
log.Panicln(err)
}
_ = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
_ = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1)
// 监听
if err := syscall.Listen(fd, syscall.SOMAXCONN); err != nil {
log.Panicln(err)
}
fmt.Printf("[%d]tcp server listen at 0.0.0.0:%d\n", os.Getpid(), port)
if err := poller.AddRead(fd); err != nil {
log.Panicln(err)
}
// 创建server
return &Server{
fd: fd,
poller: poller,
}
}
// Serve .
func (s *Server) Serve() error {
for {
events, err := s.poller.Polling()
if err != nil {
fmt.Printf("polling error: %v\n", err)
continue
}
for _, event := range events {
// server 事件
if event.Fd == s.fd {
client, err := s.accept()
if err != nil {
fmt.Printf("accept error: %v\n", err)
continue
}
clients[event.Fd] = client
s.onConnect(client)
continue
}
client, ok := clients[event.Fd]
if !ok {
fmt.Printf("client %d not found\n", event.Fd)
continue
}
// 可读
if event.Opcode == drive.OpcodeRead {
n, buf, err := client.Read(1024)
if err != nil {
if err == io.EOF {
client.Close()
go s.onClose(client)
continue
}
fmt.Printf("read error: %v\n", err)
continue
}
// 断开连接
if n == 0 {
client.Close()
go s.onClose(client)
continue
}
go s.onMessage(client, buf, n)
}
// 可写
if event.Opcode == drive.OpcodeWrite {
client.writing()
}
}
}
}
// accept .
func (s *Server) accept() (*Client, error) {
fd, sockaddr, err := syscall.Accept(s.fd)
if err != nil {
return nil, err
}
client := &Client{
fd: fd,
addr: sockaddr,
buffer: &bytes.Buffer{},
poller: s.poller,
}
clients[client.fd] = client
// 设置为非阻塞
if err := syscall.SetNonblock(fd, true); err != nil {
return nil, err
}
_ = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, 1024*128)
// 添加可读事件
if err = s.poller.AddRead(fd); err != nil {
return nil, err
}
return client, nil
}
main.go
package main
import (
"fmt"
"io-multiplexing/drive"
"log"
"strings"
)
var clients map[int]*Client
func init() {
clients = make(map[int]*Client)
}
func main() {
//poller := drive.NewSelect() // select
//poller := drive.NewPoll() // poll
poller := drive.NewEPoll() // epoll
server := NewServer(poller, 7000)
server.onConnect = func(client *Client) {
fmt.Println("onConnect", client.fd)
client.Write([]byte(strings.Repeat("A", 1024*1024*10)))
client.Write([]byte("hello world"))
}
server.onMessage = func(client *Client, message []byte, n int) {
fmt.Printf("onMessage %s", string(message[:n]))
client.Write([]byte(fmt.Sprintf("reply %s", string(message[:n]))))
}
server.onClose = func(client *Client) {
fmt.Println("onClose", client.fd)
}
if err := server.Serve(); err != nil {
log.Panicln(err)
}
}
Server启动后,就可以根据定义的回调函数收到来自客户端的消息了,并且传入不同的驱动也需要改动Server。
由于篇幅过长,部分代码未整理到文章中,完整代码已经开源到Github,有兴趣的请查阅仓库:github.com/ikilobyte/i…
声明:代码仅作为示例演示,未在生产环境经过验证。
总结
Select
- 适合小型程序,文件描述符数量少。
- 对兼容性要求较高的场景。
- 不适用于高并发或复杂的网络应用。
Poll
- 比 select 更灵活,但性能在高并发下仍受限。
EPoll
- 最佳选择,用于高并发和大规模长连接的场景(如 WebSocket、聊天室)。
- 复杂但高效,现代网络应用的标准选择。
好了,觉得喜欢的话还请点赞,分享,加关注。万分感激!