0%

《操作系统》Linux之epoll

下图是进程监听一个socket(也被linux视为文件,创建socket会返回一个fd)数据并处理的全过程

每开启一个TCP连接都是要占用一个FD文件描述符,网络服务器需要处理连接高并发,需要同时监视很多个fd的事件,所以需要解决方案处理fd的事件监听,也就是高并发

早期linux的方案是使用多路I/O复用,一开始是select

select

select的实现思路很直接。假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中。

当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。

所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。如下图所示。

经由这些步骤,当进程A被唤醒后,它知道至少有一个socket接收了数据。程序只需遍历一遍socket列表,就可以得到就绪的socket。

这种简单方式行之有效,在几乎所有操作系统都有对应的实现。

但是简单的方法往往有缺点,主要是:

  1. 每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。

  2. 进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。

那么,有没有减少遍历的方法?有没有保存就绪socket的方法?这两个问题便是epoll技术要解决的。

poll

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.

epoll(event poll)

epoll是在select出现N多年后才被发明的,是select和poll的增强版本。

epoll是同步IO(读写IO仍然是同步的),是多路复用IO(一个协程可以同时处理多个fd的IO),通过设置文件描述符可以使epoll成为阻塞或非阻塞。

下面是一段golang使用epoll的代码(只能在linux上运行,因为epoll是linux上的机制), 一个协程可以同时处理多个fd的IO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package main
import (
"fmt"
"net"
"os"
"syscall"
)
const (
EPOLLET = 1 << 31
MaxEpollEvents = 32
)
func echo(fd int) {
defer syscall.Close(fd)
var buf [32 * 1024]byte
for {
nbytes, e := syscall.Read(fd, buf[:]) // 因为前面设置了非阻塞模式,这里即使没有数据可读也不会阻塞,而是返回EAGAIN错误
if nbytes > 0 {
fmt.Printf(">>> %s", buf)
syscall.Write(fd, buf[:nbytes])
fmt.Printf("<<< %s", buf)
}
if e != nil {
// 可以通过检查EAGAIN错误来达到阻塞当前协程的目的
if err == syscall.EAGAIN { // EAGAIN错误表示 there is no data available right now, try again later
if err = fd.pd.WaitRead(); err == nil { // 阻塞当前协程直到有数据可读被唤醒
continue
}
}
break
}
}
}
func main() {
var event syscall.EpollEvent
var events [MaxEpollEvents]syscall.EpollEvent
fd, err := syscall.Socket(syscall.AF_INET, syscall.O_NONBLOCK|syscall.SOCK_STREAM, 0) // 创建一个Socket套接字,返回一个文件描述符
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer syscall.Close(fd)
if err = syscall.SetNonblock(fd, true); err != nil { // 将fd设置为非阻塞模式。阻塞的意思是指,当试图对该文件描述符进行读写时,如果当时没有东西可读或者暂时不可写,程序就进入等待状态,直到有东西可读或者可写为止。 非阻塞的意思是,当没有东西可读或者不可写时,读写函数就马上返回,而不会等待。
fmt.Println("setnonblock1: ", err)
os.Exit(1)
}
addr := syscall.SockaddrInet4{Port: 2000}
copy(addr.Addr[:], net.ParseIP("0.0.0.0").To4())
syscall.Bind(fd, &addr) // 绑定ip以及端口
syscall.Listen(fd, 10) // 开启监听
epfd, e := syscall.EpollCreate1(0) // 创建epoll实例,传入内核保证能够正确处理的最大句柄数
if e != nil {
fmt.Println("epoll_create1: ", e)
os.Exit(1)
}
defer syscall.Close(epfd)
event.Events = syscall.EPOLLIN
event.Fd = int32(fd)
if e = syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, fd, &event); e != nil { // 向epoll注册本socket的EPOLLIN事件,也可以继续注册其他socket的其他事件
fmt.Println("epoll_ctl: ", e)
os.Exit(1)
}
for {
nevents, e := syscall.EpollWait(epfd, events[:], -1) // 等待epoll收到事件后唤醒当前进程,取出监听事件的所有发生的事件
if e != nil {
fmt.Println("epoll_wait: ", e)
break
}
for ev := 0; ev < nevents; ev++ {
if int(events[ev].Fd) == fd { // 如果是本socket(server)的事件,说明有新连接
connFd, _, err := syscall.Accept(fd) // 接受连接,生成一个连接fd
if err != nil {
fmt.Println("accept: ", err)
continue
}
syscall.SetNonblock(fd, true) // 将fd设置为非阻塞模式
event.Events = syscall.EPOLLIN | EPOLLET
event.Fd = int32(connFd)
if err := syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, connFd, &event); err != nil { // 注册这个连接fd的EPOLLIN事件(就是有数据到达的事件),并设置为边缘触发EPOLLET模式
fmt.Print("epoll_ctl: ", connFd, err)
os.Exit(1)
}
} else { // 如果不是本socket(server)的事件,说明是各种连接fd(新连接到达后向epoll注册的监听)的事件,也就是连接有数据到达
go echo(int(events[ev].Fd))
}
}
}
}

epoll事件有两种模型

LT(level trigger)水平触发模型

当有事件发生时,事件一直通知用户层(就是epoll_wait的时候一直可以获取到这个事件)

处理过程:

  1. accept一个连接,添加到epoll中监听EPOLLIN事件
  2. 当EPOLLIN事件到达时,read fd中的数据并将socket移出epoll然后处理数据
  3. 当需要写出数据时,把 socket 加入 epoll ,等待EPOLLOUT可写事件,事件到达时把数据write到fd中,写完后把socket移出epoll

ET(edge trigger)边缘触发模型

事件只在边缘处触发一次。Golang是采用这种方式

处理过程:

  1. accept一个一个连接,添加到epoll中监听EPOLLIN|EPOLLOUT事件
  2. 当EPOLLIN事件到达时,read fd中的数据并处理,read需要一直读,直到返回EAGAIN为止
  3. 当需要写出数据时,把数据write到fd中,直到数据全部写完,或者write返回EAGAIN
  4. 当EPOLLOUT事件到达时,继续把数据write到fd中,直到数据全部写完,或者write返回EAGAIN



微信关注我,及时接收最新技术文章