IO多路复用 select vs poll vs epoll

257 阅读8分钟

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、聊天室)。
  • 复杂但高效,现代网络应用的标准选择。

好了,觉得喜欢的话还请点赞,分享,加关注。万分感激!