IO多路复用 | 青训营笔记

95 阅读4分钟

这是我参与「第五届青训营 」笔记创作活动的第14天

IO多路复用

IO模型

IO在计算机中指输入和输出。程序在运行时操作的数据必须在内存中,涉及到数据交换的地方,通常是磁盘、网络等,就需要IO。

进程无法直接操作I/O设备,必须通过系统调用,先把数据读取到内核缓冲区,再从内核缓冲区复制数据到进程空间。

BIO(同步阻塞IO):进程发起IO系统调用后,进程被阻塞,转到内核空间处理,整个IO处理完毕后返回进程,操作成功则进程获取到数据。

NIO(同步非阻塞IO):进程发起IO系统调用后,进程不阻塞,不断轮询查看内核缓冲区是否有数据

IO复用(异步阻塞IO):一个线程内同时检查多Socket的内核缓冲区是否准备好数据,可用则拷贝到用户空间处理

异步IO(异步非阻塞IO):内核数据准备好,并拷贝到用户态后通知线程处理

select&poll&epoll

三者都是linux内核实现IO多路复用的实现

select

select()机制中提供一fd_set的数据结构,实际上是一long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一socket或文件发生了可读或可写事件。

  • 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态
  • 内核采用的是线性遍历的方式,fd较多时开销大
  • select 支持的文件描述符数量有限,默认是 1024

poll

poll 的原理与 select 非常相似,区别在于poll使用 pollfd 链表结构,不受数量限制

epoll

epoll是被动触发,在获取事件的时候,无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了

  • 就绪列表是 fd 事件就绪之后放置的特殊地点,epoll 池只需要遍历这个就绪链表,就能给用户返回所有已经就绪的 fd 数组
  • 内部管理 fd 使用了高效的红黑树结构管理,做到了增删改之后性能的优化和平衡
  • 水平触发LT:当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上次没读写完的文件描述符上继续读写
  • 边缘触发ET:当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你

简单实践

这里是Go语言的IO多路复用TCP服务器简单实现:

epoll相关

创建Epoll

func MkEpoll() (*epoll, error) {
	fd, err := unix.EpollCreate1(0)
	if err != nil {
		return nil, err
	}
	return &epoll{
		fd:          fd,
		lock:        &sync.RWMutex{},
		connections: make(map[int]net.Conn),
	}, nil
}

Epoll池注册事件

func (e *epoll) Add(conn net.Conn) error {
	// Extract file descriptor associated with the connection
	fd := socketFD(conn)
	err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_ADD, fd, &unix.EpollEvent{Events: unix.POLLIN | unix.POLLHUP, Fd: int32(fd)})
	if err != nil {
		return err
	}
	e.lock.Lock()
	defer e.lock.Unlock()
	e.connections[fd] = conn
	if len(e.connections)%100 == 0 {
		log.Printf("total number of connections: %v", len(e.connections))
	}
	return nil
}

注册前需要将连接转换为fd描述符

func socketFD(conn net.Conn) int {
	tcpConn := reflect.Indirect(reflect.ValueOf(conn)).FieldByName("conn")
	fdVal := tcpConn.FieldByName("fd")
	pfdVal := reflect.Indirect(fdVal).FieldByName("pfd")

	return int(pfdVal.FieldByName("Sysfd").Int())
}

获取准备好的的就绪队列

func (e *epoll) Wait() ([]net.Conn, error) {
	events := make([]unix.EpollEvent, 100)
retry:
	n, err := unix.EpollWait(e.fd, events, 100)
	if err != nil {
		if err == unix.EINTR {
			goto retry
		}
		return nil, err
	}
	e.lock.RLock()
	defer e.lock.RUnlock()
	var connections []net.Conn
	for i := 0; i < n; i++ {
		conn := e.connections[int(events[i].Fd)]
		connections = append(connections, conn)
	}
	return connections, nil
}

在Epoll中注销该连接

func (e *epoll) Remove(conn net.Conn) error {
	fd := socketFD(conn)
retry:
	err := unix.EpollCtl(e.fd, syscall.EPOLL_CTL_DEL, fd, nil)
	if err != nil {
		if err == unix.EINTR {
			goto retry
		}
		return err
	}
	e.lock.Lock()
	defer e.lock.Unlock()
	delete(e.connections, fd)
	if len(e.connections)%100 == 0 {
		log.Printf("total number of connections: %v", len(e.connections))
	}
	return nil
}

主函数

选择端口号监听

ln, err := net.Listen("tcp", ":8080")

创建epoll,并把接收到的连接加入epoll

    ln, err := net.Listen("tcp", ":8080")
	for {
		conn, e := ln.Accept()
		if e != nil {
			if ne, ok := e.(net.Error); ok && ne.Temporary() {
				log.Printf("accept temp err: %v", ne)
				continue
			}

			log.Printf("accept err: %v", e)
			return
		}

		if err := epoller.Add(conn); err != nil {
			log.Printf("failed to add connection %v", err)
			conn.Close()
		}
	}

遍历就绪队列,处理数据,以下代码由于与上面的代码都是阻塞的,二者需要并发运行

	for {
		connections, err := epoller.Wait()
		if err != nil {
			log.Printf("failed to epoll wait %v", err)
			continue
		}
		for _, conn := range connections {
			if conn == nil {
				break
			}

			// 将消息(时间戳)原封不动的写回
			_, err = io.CopyN(conn, conn, 8)
			if err != nil {
				if err := epoller.Remove(conn); err != nil {
					log.Printf("failed to remove %v", err)
				}
				conn.Close()
			}

			opsRate.Mark(1)
		}
	}