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 多线程采集架构图

下面这张图,是我个人习惯用的多线程采集架构。你可以参考一下。

多线程采集安全架构 多线程采集安全架构 App 事件数据源 采集线程组(Thread Pool) 线程1 | 线程2 | 线程3 | ... | 线程N 线程安全层 互斥锁 | 无锁队列 | 线程局部存储 原子操作 | 内存屏障 | RAII 数据处理(压缩/加密/序列化) 每个阶段独立加锁 避免全局锁

3.5 线程安全检查清单

最后,我整理了一份检查清单。每次写多线程采集代码,我都会过一遍。

检查项 说明 优先级
共享资源识别 明确哪些变量/缓冲区被多线程访问 高
锁的粒度 锁的范围是否合理?是否过度加锁? 高
死锁检测 是否存在循环等待?是否忘记解锁? 高
原子操作 计数器、标志位是否用了原子类型? 中
内存屏障 无锁队列是否保证了可见性? 中
异常安全 异常发生时锁是否释放?资源是否泄漏? 高

一个小技巧:写多线程代码的时候,我习惯先在纸上画出线程交互图。哪个线程读,哪个线程写,共享资源是什么,一目了然。画清楚了再写代码,能避免80%的线程安全问题。