3. 多线程采集冲突:多线程环境下数据采集错乱,如何保证线程安全?
多线程采集,听起来挺美——一个线程拉页面,一个线程传数据,一个线程写日志,效率翻倍。但实际跑起来,你会发现数据错乱得让人抓狂。
我刚开始做SDK的时候,就踩过这个坑。明明采集了100条事件,结果数据库里只存了80条,还有20条莫名其妙丢了。排查了半天,才发现是多个线程同时写同一个缓冲区,互相覆盖了。
说白了,多线程采集的核心问题就一个:共享资源的访问冲突。多个线程同时读、同时写,数据不乱才怪。
3.1 冲突到底发生在哪?
先看看典型的SDK采集流程。一个采集线程从App获取事件数据,另一个线程负责压缩加密,第三个线程负责网络发送。这三个线程如果操作同一个内存缓冲区,冲突就来了。
常见冲突场景:
- 写写冲突:两个线程同时往缓冲区写数据,后写的覆盖先写的
- 读写冲突:一个线程在读数据,另一个线程在写,读到一半的数据是脏数据
- 删除冲突:一个线程删除了数据,另一个线程还在引用它,直接崩溃
我记得有一次线上事故,用户反馈App频繁闪退。查了日志发现,是采集线程在释放内存的时候,发送线程还在用那块内存。嗯,典型的读写冲突。
3.2 保证线程安全的三大武器
解决多线程冲突,说白了就三招:锁、无锁队列、线程局部存储。我一个个说。
3.2.1 互斥锁(Mutex)
最直接的办法,就是给共享资源加把锁。谁拿到锁谁操作,操作完再释放。
// 伪代码示例:互斥锁保护缓冲区
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void write_event(Event event) {
pthread_mutex_lock(&lock);
// 写入缓冲区
buffer.push(event);
pthread_mutex_unlock(&lock);
}
Event read_event() {
pthread_mutex_lock(&lock);
Event event = buffer.pop();
pthread_mutex_unlock(&lock);
return event;
}
注意:锁不是万能的。锁的粒度太粗,会导致其他线程长时间等待,性能下降。我见过一个团队,把整个采集流程都锁住了,结果采集速度还不如单线程。
3.2.2 无锁队列(Lock-Free Queue)
锁虽然安全,但性能开销大。尤其是在高并发场景下,锁竞争会拖慢整体速度。这时候,无锁队列就派上用场了。
无锁队列利用CAS(Compare-And-Swap)原子操作,保证多线程同时读写时数据一致。说白了,就是硬件层面帮你解决了冲突。
// 伪代码示例:无锁队列
AtomicQueue queue;
void producer(Event event) {
while (!queue.push(event)) {
// 重试直到成功
}
}
Event consumer() {
Event event;
while (!queue.pop(&event)) {
// 重试直到成功
}
return event;
}
我的建议:如果采集频率不高(每秒几百次),用互斥锁就够了。如果采集频率高(每秒上万次),无锁队列是更好的选择。我在一个日活千万的App里用过无锁队列,性能提升很明显。
3.2.3 线程局部存储(Thread Local Storage)
还有一种思路,就是让每个线程都有自己的独立缓冲区,互不干扰。这样就不存在共享资源了,自然也就没有冲突。
// 伪代码示例:线程局部存储
__thread Buffer thread_buffer;
void collect_event(Event event) {
thread_buffer.push(event);
if (thread_buffer.size() >= BATCH_SIZE) {
flush_to_network(thread_buffer);
}
}
每个线程只操作自己的缓冲区,最后再统一合并。这种方式避免了锁竞争,但需要额外的合并逻辑。
3.3 实战中的避坑指南
理论说完了,聊聊实战中我踩过的坑。
我曾经犯过的错:
- 忘记解锁:代码里加了锁,但异常分支忘了释放,导致死锁。后来我强制用RAII(资源获取即初始化)模式,锁的作用域结束自动释放。
- 锁的粒度太细:每个变量都加锁,结果锁的开销比采集本身还大。后来我合并了锁的范围,性能反而提升了。
- 忽视内存屏障:无锁队列虽然不用锁,但需要内存屏障保证可见性。我一开始没加,结果一个线程写的数据,另一个线程读不到。
3.4 多线程采集架构图
下面这张图,是我个人习惯用的多线程采集架构。你可以参考一下。
3.5 线程安全检查清单
最后,我整理了一份检查清单。每次写多线程采集代码,我都会过一遍。
| 检查项 | 说明 | 优先级 |
|---|---|---|
| 共享资源识别 | 明确哪些变量/缓冲区被多线程访问 | 高 |
| 锁的粒度 | 锁的范围是否合理?是否过度加锁? | 高 |
| 死锁检测 | 是否存在循环等待?是否忘记解锁? | 高 |
| 原子操作 | 计数器、标志位是否用了原子类型? | 中 |
| 内存屏障 | 无锁队列是否保证了可见性? | 中 |
| 异常安全 | 异常发生时锁是否释放?资源是否泄漏? | 高 |
一个小技巧:写多线程代码的时候,我习惯先在纸上画出线程交互图。哪个线程读,哪个线程写,共享资源是什么,一目了然。画清楚了再写代码,能避免80%的线程安全问题。