对于高负载的网络服务器, 瓶颈几乎总是在等待 IO, 而 CPU 的计算能力往往不是最先遇到的问题. 你的服务器程序, 接收客户端的请求, 可能还要连接另一台网络服务器, 一起合作处理客户端的请求. 很多情况下, 你无法把客户端的连接以及与另一台服务器的连接统一处理, 不可避免地要出现等待. 这时, 只能使用多进程或者多线程.
Master-Workers(管理者-工作者)模式是处理这种情况的主要方式, 只要有 IO 等待或者其它耗时的操作, 都交互若干个工作者之一处理, 这样, 后续的请求不会被阻塞, 从而实现高负载.
目录:
下文中有时用"进程"一词同时指代进程和线程, 它们的区别主要考虑的是通信方式.
1. 单进程(单线程)网络服务器的结构
单进程网络服务器使用 IO 多路利用接口 - select(doc)/epoll(doc)/kqueue(doc) 等 - 等待网络连接的信号, 收到读信号后, 接收一个应用层报文(如HTTP请求或者其它自定义的报文), 处理, 然后返回响应. 结构如下:
// 主循环 while(1){ events = select.wait(timeout=100ms) foreach(events as e){ req = e.sock.recv() resp = server.process(req) e.sock.send(resp) } }
很明显, 如果 process 方法处理的时间较长的话, 那么并发量就很小了, 因为下一个请求必须等待前一个被处理完毕之后才能处理. 这时候, 只能使用多进程或者多线程, 创建所谓的工作者(Worker)进程.
2. 多进程工作者模式(Master-Workers)
首先有一个 IO 进程(称为 Master), 也就是循环执行 select 的那个进程. 另有若干个工作者进程(称为 worker), 等待 IO 进程把报文给它们. 这也是一种"生产者-消费者"的模型. 具体上, 到底 IO 进程应该把哪一层次的数据交给工作者, 是简单的字节数组, 还是应用层的报文, 不同的应用不同设计. 现在假设传递的是完整的应用层报文, 也就是说, IO 进程负责报文解析, 如果报文的解析只消耗极少的资源, 这是最佳的方式.
2.1 Master-Workers 双向通信(长连接之一)
// 启动 10 个工作者 Workers = start_Workers(num=10) // 主循环 while(1){ resps = Workers.wait(Workers_timeout=10ms); if(resps.count > 0){ foreach(resps as resp){ resps.sock.send(resp) } } events = select.wait(select_timeout=10ms) if(events > 0){ foreach(events as e){ req = e.sock.recv() // 仅仅是委托, 会立即返回. 以后通过 Workers.wait() 获取响应. Workers.delegate(req) } } }
Workers 的工作模式是:
while(1){ req = req_queue.pop(); resp = process(req) // 响应被加入到发送队列中 resp_queue.push(resp) }
wokers.wait 是等待工作者的处理结果事件(从响应队列中读取响应), 而 select.wait 是等待网络事件. 两者逻辑上相同. Workers 和 select 应该等待多久(即 timeout 的取值是多少)? 如果 Workers_timeout 太大, 那么就会导致 Workers_timeout 时间内即使有新的请求进来, 也无法及时处理, 因为 select.wait 还没被执行到. 相对比, 如果 select_timeout 太大, 那么又会导致 select_timeout 时间内响应无法及时发出, 因为程序阻塞在了 select.wait.
2.2 Master-Workers 单向通信(短连接)
短连接是指一次请求响应后便关闭的连接, 而不是时间长短.
那么, 能不能让 Workers.wait(及其对应的网络 send 操作) 和 select.wait(及其对应的网络 recv 操作) 运行在两个不同的线程里? 答案是可以的. 但是, 把同一个连接用两个线程(一个是 select.wait, 另一个是 Workers.wait)来操作, 有时很难处理多线程问题. 还有一个重要的问题, 如果把网络发送放在工作者线程里, 那么一个连接的网络发送阻塞, 会导致其中一整个线程完全失去效用, 仅仅是因为阻塞. 我们不仅要考虑网络读阻塞, 也要考虑网络写阻塞的问题. 不过, 如果是基于短连接的模式, 是可以这么做的, 如果一个响应不会超过 socket 发送缓冲大小的话, send 永远不会阻塞. 程序的结构改变为:
// 启动 10 个工作者 Workers = start_Workers(num=10) // 主循环 while(1){ events = select.wait(select_timeout=10ms) if(events > 0){ foreach(events as e){ req = e.sock.recv() // 仅仅是委托, 会立即返回. 不关心响应. Workers.delegate(req) } } }
Workers 的工作模式是:
while(1){ req = req_queue.pop(); resp = process(req) // 响应这里发送给客户端 resp.send() resp.close() }
对于短连接, 这种工作模式非常好. 因为 resp.send() 和 resp.close() 非常快, 操作系统保证调用它们几乎不会阻塞. 但是, 这种模式无法扩展到多台机器.
2.3 Master-Workers 双向通信(长连接之二)
对于长连接, 是否还有更好的处理方法呢? 有的, 那就是把 Worker 做成可 select 的(Selectable), 比如 Master 和 Worker 通过网络通信, 那么就可以 select 处理结果了. 程序结构如下:
// 启动 10 个工作者 Workers = start_Workers(num=10) // 主循环 while(1){ events = select.wait(select_timeout=10ms) if(events > 0){ foreach(events as e){ if(req.type == USER_REQ){ req = e.sock.recv() // 仅仅是委托, 会立即返回. 会通过 select 得到响应. Workers.delegate(req) }else if(req.type == WORKER_RESPONSE){ resp = req; resp.sock.send(resp) } } } }
在主循环中只有一个地方会等待, 无论是网络事件, 还是工作者事件, 都会立即唤醒 select.wait, 使事件得到及时得到响应.
这种方式是得到普遍应用的, 比如 lighttpd 的主循环IO 进程和 fastcgi 的通信就是这种方式. 当 Master 和 Worker 的通信使用的是网络 socket 的话, 可扩展性比 PIPE 更高.
欢迎讨论!
只不过后续的process 交给worker 来处理 Reply