前言
前面介绍了unix系统中的IO复用技术(Kqueue),并且基于此技术实现了两种简单的echo服务器
- 同步、单线程的echo服务器。允许多个client连接,但是同时只能处理一个client的请求,需要该client断开连接后才能继续处理下一个客户端的请求
- 异步、多线程的echo服务器。允许多个client连接并且同时可以处理多个client的请求,是一个比较贴近实际场合的echo服务器,但是多线程会带来频繁的线程间切换以及加解锁,实际开销比较大
仔细观察思考,实际上上述两种方法程序的时间大多也是阻塞在client的读写上,如果我们在等待某个客户端读写时将程序切换到另一个正在读写的client上,充分利用IO多路复用的特点,就可以用少量线程实现类似多线程“同时”处理的效果,并把资源的利用率降到最低,这也是reactor模式需要解决的问题。
reactor模式简介
网上有很多对reactor设计模式的介绍,这里我就简单的提一下,感兴趣的小伙伴可以自己查阅更详细的资料
在reactor模式中,每个连接需要完成的操作分为很多小的event,比较典型的有连接、读和写这三个event,相应处理这些事件的操作被称为handler。另外还有一个全局的selector(这里是Kqueue)监视是否有相应的event发生,如果有,就调用相应的handler进行处理,否则主进程就会被阻塞直至有event发生。
reactor模式可以带来很多好处,比如
- 响应快,尽管是同步处理事件,但不会阻塞在单一事件上
- 资源消耗低,同时避免多线程带来的加解锁和切换开销
- 可扩展性,可以增加reactor的数量来提升性能
- 可复用性,框架本身不含逻辑,具有很强的复用性
但是reactor模式也有一些缺点:
- 结构相较而言比较复杂,且不易于调试
- 不适合长时间的io读写型应用,如果某个client长时间占据io会挤占其余client的时间,会导致其余client的响应延时大幅度增加,这种情况下应该采用thread-per-connection,或者pre-reactor模式
基于reactor的echo服务器
好了言归正传,我们的echo服务器完全符合reactor模式的应用特点,下面介绍如何在unix系统上实现相应的功能
结合简单的代码介绍,具体实现见reactor实现echo服务器
- 建立内核监听事件,初始化服务监听端口并放入监听队列等待
srv := &server{
p: newPoll(),
cm: make(map[int]*conn),
packet: make([]byte, 1024),
}
serverFd, err := createListener(&serverAddr)
if err != nil {
panic(err)
}
srv.p.addRead(serverFd) //监听端口放入changes等待
- 进入wait函数,阻塞等待客户端连接的事件发生,当有客户端连接时会进入到iter函数,这个函数是整个程序最核心的事件处理部分
func (p *Poll) wait(iter func(fd int) error) error{
events := make([]syscall.Kevent_t, 128)
for{
nev, err := syscall.Kevent(p.fd, p.changes, events, nil)
if err != nil {
return err
}
p.changes = p.changes[:0]
for i:=0; i<nev; i++ {
if err := iter(int(events[i].Ident)); err != nil {
return err
}
}
}
}
- 首先iter函数会检查新建连的fd有没有创建一个connection,没有的话就进入建立连接的handler(srv.newConnection)
srv.p.wait(func(fd int) error {
c := srv.cm[fd]
switch {
case c == nil:
return srv.newConnection(serverAddr, serverFd)
case len(c.out) > 0:
return loopWrite(srv, c)
default:
return loopRead(srv, c)
}
})
- 建联的handler函数的主要目的是接受这个新的连接,并将该fd设置为可读状态,放入内核监听队列中
func (s *server)newConnection(serverAddr syscall.SockaddrInet4, fd int) error {
clientFd, sockAddr, err := syscall.Accept(fd)
if err != nil {
return err
}
if err = syscall.SetNonblock(clientFd, true); err != nil {
return err
}
clientAddr, err := SockaddrToAddr(sockAddr)
if err != nil {
return err
}
c := &conn{
fd: clientFd,
listenAddr: serverAddr,
clientAddr: clientAddr,
out: nil,
}
s.cm[clientFd] = c
log.Printf("accept connection from %s:%d\n", strings.Replace(strings.Trim(fmt.Sprint(c.clientAddr.Addr), "[]"), " ", ".", -1), clientAddr.Port)
s.p.addRead(clientFd)
return nil
}
- 接下来又再次回到了wait函数再次阻塞,当这个连接的client开始写入数据时,相应连接的fd活跃,进入到iter函数的loopRead这个分支中。loopRead函数的主要目的是读取来自client的数据并将其放入输出缓冲中,最后修改内核监听列表中该fd的状态为可写
func loopRead(s *server, c *conn) error {
nread, err := syscall.Read(c.fd, s.packet)
if err != nil {
return err
}
if nread == 0 {
log.Printf("close connectrion from: %s:%d\n", strings.Replace(strings.Trim(fmt.Sprint(c.clientAddr.Addr), "[]"), " ", ".", -1), c.clientAddr.Port)
syscall.Close(c.fd)
}
c.out = s.packet[:nread]
if len(c.out) != 0 {
s.p.addWrite(c.fd)
}
return nil
}
- 又回到了wait函数,由于上面直接将fd修改为可写状态且输出缓冲有数据,此时直接进入iter函数的loopWrite事件中,这个函数的主要目的是将输出缓冲的数据全部写回client,并删除fd的可写状态。此后wait函数继续阻塞直到有新的连接或者未断的连接给服务端发送新的数据
func loopWrite(s *server, c *conn) error {
nwrite, err := syscall.Write(c.fd, c.out)
if err != nil {
return err
}
if nwrite == len(c.out) {
c.out = c.out[:0]
}
if len(c.out) == 0 {
s.p.delWrite(c.fd)
}
return nil
}
上面实现的reactor框架参考了evio的设计,可以看成是evio核心功能的简化版,之后会抽出时间逐步完善这个设计。