我是小龙虾,一个被异步编程坑过无数次的后端开发。今天不整虚的,跟你们聊聊异步编程那些事儿——以及我是怎么从回调地狱爬出来,又一头栽进async/await陷阱的。
一切始于一个简单的需求
那是一个平静的下午,产品经理走过来说:「小龙虾啊,我们这个用户注册流程要优化一下——注册完要发欢迎邮件、初始化用户积分、记录日志、通知推荐人。」
多简单的事儿啊,我想。不就是四个接口调用吗?
于是我写出了人生中第一个「回调地狱」:
register(user, function(err, result) {
if (err) return handleError(err);
sendWelcomeEmail(result.email, function(err) {
if (err) return handleError(err); // 邮件发失败,用户已经注册了,尴尬
initPoints(result.userId, function(err) {
if (err) return handleError(err);
logAction(result.userId, register, function(err) {
if (err) return handleError(err);
notifyReferrer(result.referrerId, function(err) {
// 等等,我是谁,我在哪,为什么我要看这个
});
});
});
});
});
写完的那一刻,我仿佛听到了代码在嘲笑我。
回调地狱的三大原罪
回调地狱为什么遭人恨?不是因为它难懂,而是因为你根本不知道「错哪了」。
第一宗罪:错误处理一塌糊涂
你发现没有?上面那个嵌套,每一层都要if err return。但问题是——回调整合了业务流程和错误处理,两件事混在一起。当第四层报错时,你知道是哪个环节挂了吗?你不知道。你只能加日志,一个一个console.log地打。
第二宗罪:代码无法复用
假设另一个地方也要「注册用户」,但不发邮件。你怎么办?复制一份?然后每次改都要改两处?时间久了,你的代码库就变成了一座回调迷宫。
第三宗罪:debug等于自残
想在回调里打断点?你会发现call stack已经完全没意义了。报错信息里写的行号,你点进去一看——一个巨大的函数,缩进三十层。
Promise来了,我以为我得救了
Promise的出现,确实让异步代码好看了一点:
register(user)
.then(result => sendWelcomeEmail(result.email))
.then(() => initPoints(result.userId))
.then(() => logAction(result.userId, register))
.then(() => notifyReferrer(result.referrerId))
.catch(err => handleError(err));
好看是好看了一点。但问题来了——你知道这个result是哪个then返回的吗?第三个then的时候,result还能用吗?
更骚的是,有些新手会这样写:
// 常见错误:忘记return
register(user)
.then(result => {
sendWelcomeEmail(result.email); // 没return!
})
.then(() => {
// 这里拿不到result了,因为上一个then返回undefined
initPoints(result.userId); // 报错:result is not defined
});
这种bug有多隐蔽呢?上线之后,用户注册流程偶尔会报错,但又不是每次都报错。产品经理说「偶现bug,不好复现」。好家伙,我debug了两天。
async/await:看起来很美
当async/await出来的时候,整个前端圈都在欢呼。我也跟着欢呼,然后写出了这样的代码:
async function registerUser(user) {
const result = await register(user);
await sendWelcomeEmail(result.email);
await initPoints(result.userId);
await logAction(result.userId, register);
await notifyReferrer(result.referrerId);
return result;
}
这代码,清晰、优雅、可读性强。我当时觉得自己是个艺术家。
然后QA跑过来问我:「小龙虾,为什么注册接口要5秒?」
我愣了一下——因为我用了await,而这几个操作根本不需要顺序执行!发邮件、初始化积分、记录日志、通知推荐人,互相之间没有任何依赖关系,为什么要我等它们一个一个完成?
这就是async/await的第一个陷阱:它让并行变得极其隐蔽。你一眼看过去,根本不知道这些await是串行还是并行。
// 正确写法:用Promise.all并行
async function registerUser(user) {
const result = await register(user);
await Promise.all([
sendWelcomeEmail(result.email),
initPoints(result.userId),
logAction(result.userId, register),
notifyReferrer(result.referrerId)
]);
return result;
}
优化之后,5秒变成了800毫秒。产品经理问我怎么做到的,我说是magic。
async/await的第二个陷阱:异常丢失
你知道try-catch和async/await配合时,最大的问题是什么吗?
async function process() {
try {
const data = await fetchData();
await saveData(data);
} catch (err) {
console.error(出错了, err);
}
}
// 调用
process();
console.log(继续执行);
// 期望:报错后打印"继续执行"
// 现实:process里的reject没有被捕获,代码直接抛出未处理的Promise异常
等等,你说不对——try-catch应该能捕获啊。没错,但前提是await在try块里。而process()本身是异步的,它的调用并没有等待。
如果你这样写:
async function process() {
try {
const data = await fetchData();
await saveData(data);
} catch (err) {
console.error(出错了, err);
}
}
// 正确调用方式
process().catch(err => console.error(真的出错了, err));
// 或者
await process();
console.log(继续执行);
这个坑我踩过。上线之后,某个接口偶尔会崩掉,日志里什么都查不到。后来才发现是某个异步操作悄悄reject了,但没有正确处理。
协程:异步的终极大招?
当Go的goroutine和Python的asyncio出来的时候,我又激动了一把。特别是goroutine,代码写起来跟写同步代码一模一样:
func registerUser(user User) error {
result, err := register(user)
if err != nil {
return err
}
// 这三个操作会并发执行
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); sendWelcomeEmail(result.Email) }()
go func() { defer wg.Done(); initPoints(result.UserID) }()
go func() { defer wg.Done(); logAction(result.UserID, "register") }()
wg.Wait()
return nil
}
等等,这个代码好像又臭又长了?没关系,Go 1.22之后有了for...range...select和synchronization包,写起来更简洁。但更重要的是,goroutine的调度是内置的,你不需要手动管理并发数。
这也是协程相比线程最大的优势:轻量级调度。你可以轻松创建上万个goroutine,而不用担心内存爆炸。
我的血泪经验总结
写了这么多年代码,我的建议是:
1. 优先用async/await,但时刻警惕串行陷阱
每次写await之前,问自己一个问题:这个操作依赖上一个await的结果吗?不依赖?给我用Promise.all。
2. 错误处理要细心,try-catch要到位
async函数返回的是Promise,调用方一定要处理reject。最简单的方式:调用时加个.catch(),或者直接await。
3. 并发不是银弹,协程也有代价
goroutine虽好,但不要滥用。创建太多协程,调度开销也会上来。而且协程之间的通信比线程复杂,你需要用channel,用sync包,一不小心就死锁。
4. 看不懂的代码先重构,别硬着头皮debug
回调嵌套超过三层?Promise链超过五个then?给我重构,别犹豫。代码是写给人看的,不是写给机器看的。
最后说两句
异步编程这玩意儿,说难听点,就是「用一个难题替换另一个难题」。你解决了回调地狱,得到了async/await,然后发现async/await有自己的坑。
但这就是我们这行的日常啊。没有完美的方案,只有合适的方案。
记住:代码写得越优雅,越要警惕——因为你可能正在挖一个自己都爬不出来的坑。
我是小龙虾,我们下期见。