Web 后端开发中的异步和非堵塞 IO

当下追新的开发者会尝试用 Node.JS 来做 Web 后端开发,相信这部分人在此之前是以 PHP 开发而主,但由于 PHP 的单进程、堵塞 IO 特性而无法获取很高性能而转移阵线到 Node.JS 上。不过我认为这是个错误的决定,因 Node.JS 基于 CallBack 的异步模式实现的非堵塞 IO 操作并不适合 Web 开发场景。

非堵塞与堵塞

堵塞 IO,即业务逻辑发起 IO 操作后会把整个业务堵塞在此处,等待 IO 操作结果后再继续运行,会导致 CPU 空转。而非堵塞 IO 则不会堵塞业务逻辑,发出 IO 操作后可以继续做其他事情。那么那个性能更好就很明显了。

如何使用非堵塞 IO

因非堵塞 IO 在发出 IO 操作后并不知道何时可以收到结果,我们必须为此加入接收结果的代码。看过 Node.JS 的 CallBack 异步回调可以更容易明白

query('SELECT * FROM table', function(result){...})

上面的代码发起一个非堵塞的 IO 操作,并使用一个 function 来异步接收结果。而在 C 或 Java 这些支持多线程的语言中,我们是使用多线程来发起非堵塞 IO 操作的,等结果来时再唤醒线程,继续运行接下去的业务逻辑。

可以说 Node.JS 的 CallBack 异步回调开发模式,简化了业务开发者的工作,不需要开发者自己开启线程和线程调度。这也是现在这么多人会使用 Node.JS 的原因。

但并不只有 CallBack 异步回调的开发模式才能使用非堵塞 IO,可能大家对协程的了解还不多,否则你也会选择协程。其实协程更适合用来做这些工作,协程是专为非堵塞 IO 而生的好东西。它相对于多线程所消耗的资源更少、性能更高,重点是使用协程可以让你以同步逻辑的思维来书写业务代码。

results = query('SELECT * FROM table')

一眼看过去,似乎是 PHP 这样的堵塞 IO 操作啊?非也,我们要看 query 函数里面的东西才能下定论 :)

function query(sql)
send_query(db, sql)
yield()
return read_result(db)
end

看上面的代码是否跟堵塞 IO 的很像?但请注意 yield 这个函数的调用,在协程的世界中 yield 即把当前运行权交出,等待程序主体的 resume 再运行接下去的代码,当我们的程序主体接收到该次 IO 操作的结果,resume 协程即可。如果我们把 yield 函数的调用都去掉,就是堵塞 IO 的逻辑,而加上则支持非堵塞 IO 操作了。

OK! 现在我们知道非堵塞 IO 可以使用异步回调和协程两种方式来操作!

那为什么说 CallBack 模式不适合 Web 场景?

我们在这里举一个业务例子,分别使用两种方式看看代码是怎样的

业务需求:
1. 读取一个会员信息,获得 uid
2. 并根据 uid 查询对应的文章记录
3. 根据 uid 查询对应的回复记录

使用 Node.JS 会这样写业务代码:

query('SELECT uid FROM users WHERE username=\'aaa\'',
function(uid){
query("SELECT * FROM Article WHERE uid="+uid,
function(res){
query("SELECT * FROM Reply WHERE uid="+uid,
function(res2){
console.log(uid, res, res2);
}
)
}
)
}
)

使用 Lua 类协程方式会这样写业务代码:

uid = query('SELECT uid FROM users WHERE username=\'aaa\'')
res = query("SELECT * FROM Article WHERE uid="+uid)
res2 = query("SELECT * FROM Reply WHERE uid="+uid)
print(uid, res, res2)

上面两段代码都使用非堵塞 IO,但很明显的看到,使用协程方式书写的代码其实跟堵塞 IO 的完全没区别,非常精简。而看 Node.JS 基于异步回调方式写的代码,就有很长的嵌套“尾巴”,多个累赘的 function 声明。

上面的代码例子是串行逻辑的情景,也许你想说在这个情景下 Node.JS 会吃亏。好的,我们写个并行的代码来看看

Node.JS

query('SELECT uid FROM users WHERE username=\'aaa\'',
function(uid){
async.parallel({
res: query("SELECT * FROM Article WHERE uid="+uid),
res2: query("SELECT * FROM Reply WHERE uid="+uid)
},
function(result) {
console.log(uid, result.res, result.res2);
});
}
)

Lua

uid = query("SELECT uid FROM users WHERE username='aaa'")
wait(
newthread(function() res = query("SELECT * FROM Article WHERE uid="+uid) end),
newthread(function() res2 = query("SELECT * FROM Reply WHERE uid="+uid) end)
)
print(uid, res, res2)

Node.JS 即使引入 Async 类的功能支持都无法回避嵌套,只能把嵌套的层级尽量缩小而已,嵌套达到5层的代码就很难维护了。而 Lua 这些基于协程方式实现的非堵塞 IO 操作则精简得多,业务代码很清晰,也更容易维护。

CallBack 回调模式适合什么业务场景?

CallBack 回调的开发模式很适合客户端场景,因客户端与客户有很多交互动作,并且这些动作都是未知的,我们总不能每时每刻去问下,客户点击某个按钮了吗?按了我就得做什么事情去了。在这样场景下,我们更希望在每个按钮上绑定一个事件通知的回调,让客户点击某个按钮时会触发我去做相应的事情。

而 Web 后端开发场景上明显不是上面所说的,完全不同。所有事件的产生你都知道,因为是你发起的非堵塞 IO 操作,你可以用更好的方式来得到事件的结果,而不是 CallBack 回调。

对协程开发有兴趣的同学可以考虑:

OpenResty: http://openresty.org/
一个基于 Nginx + Lua 的高性能 Web 应用服务器

aLiLua: http://alilua.com/
一个基于 Epoll + Lua 的高性能 Web 开发框架
(PS:这也是我的开源项目,主打提供一整套 Web 后端开发所需的支持)