2026/1/23 17:22:02
网站建设
项目流程
阿里云建站后台,北京建设工程信息网官网入口,中企动力科技股份有限公司网站,wordpress 基于 网店C语言实战#xff1a;手搓高并发异步日志库#xff08;基于 Ring Buffer 生产者消费者模型#xff09;
1. 为什么 printf 不够用#xff1f;
在实际项目中#xff0c;尤其是嵌入式设备、实时系统或高并发服务端程序里#xff0c;很多人一开始都习惯直接用 printf、fpr…C语言实战手搓高并发异步日志库基于 Ring Buffer 生产者消费者模型1. 为什么printf不够用在实际项目中尤其是嵌入式设备、实时系统或高并发服务端程序里很多人一开始都习惯直接用 printf、fprintf 或 write 把日志打到串口、文件、Flash。这种做法在demo阶段没什么问题但一旦上了生产环境才发现这玩意儿是个隐形的性能杀手。我下面列举一下常见的几个致命问题本人也不幸踩到过同步阻塞像write这样的底层接口是阻塞的而磁盘 / Flash / 串口通常是比较慢的Flash 擦除甚至能到几百毫秒业务线程就只能干等着。我之前写了个demo实测了一下直接写入 2000 条日志耗时7秒而使用异步日志仅需0.02秒性能差距达300倍。线程中断问题多任务或中断里同时 printf没有锁的话日志立刻花屏A线程打印一半B线程插进来了导致日志没法看而加锁又会带来新的性能和死锁风险。缓存刷新策略失控printf默认行缓存遇到 \n 或缓冲区满才刷在嵌入式里经常忘记fflush在嵌入式系统崩溃或断电的瞬间缓冲区里重要的数据往往还没来得及刷入磁盘就丢失了导致日志丢失都不知道。CPU 占用率爆炸高频率打日志时CPU 大部分时间都在等 I/O而不是干正事。那么正确的操作应该是怎样的呢我们要把记录日志和写入磁盘彻底解耦。业务线程只负责把日志扔进一个内存缓冲区大概几微秒然后立刻返回继续干活。 由一个独立的后台线程慢慢把缓冲区内容刷到磁盘/Flash/串口去。这就是经典的 “ 生产者消费者模型 ” 和环形缓冲区的组合。这也是很多工业级日志库的底层原理。2. 生产者-消费者模型为了解决上面提到的同步阻塞问题我们不能让业务线程直接去“碰”磁盘。我们需要引入一个中间层将产生日志和写入磁盘这两个动作彻底剥离。这正是计算机科学中经典的生产者-消费者模型的最佳应用场景。2.1 系统架构下面要介绍的这个架构也就是生产者-消费者模型这个名字的由来。我们可以把整个日志系统看成一个繁忙的餐厅的厨房。可能有人会想“那生产者就是厨师消费者就是顾客呗”我认为这个想法既合理又不合理合理是因为这个想法是符合我们的常识的不合理是因为在日志系统这个环境下是有特殊情况存在的后面会解释。在这里其实生产者相当于服务员消费者相当于厨师而他们之间有一个窗口就是缓冲区放菜单。所以整个过程其实就是服务员去给大量的客人点菜然后把菜单放在窗口厨师从窗口拿菜单然后做饭做饭可以类比为写磁盘。到这里就结束至少在生产者-消费者模型下这个场景已经很完善了。所以前面那个把顾客当成消费者的想法有点不太合适如果这样理解就相当于一个顾客坐那一直吃吃吃…胃口大的话其实也不是不行可能有的朋友还是觉得不够形象下面我放一张图大家可以参考一下纯手画的感觉画的不错的朋友点赞加关注不想的话就算了可能有细心的朋友注意到了我在图片中生产者用的Producers而消费者却用的Consumer他们一个是复数一个是单数意思是有多个生产者而只有一个消费者吗答案是肯定的。这个模型的名称是MPSC (Multi-Producer Single-Consumer) 这是高性能日志系统的标准架构。为什么消费者只有一个呢多个消费者会怎么样呢日志文件通常是一个文本文件log.txt。如果你搞了 5 个消费者线程同时往一个文件里写你需要加很重的锁来防止数据错乱比如线程 A 写了一半“Error…”线程 B 插进来写了“Warning…”。既然最终都要加锁排队写文件那多搞几个消费者线程不仅没变快反而增加了上下文切换的开销还不如就一个线程专门排队写。对于机械硬盘或嵌入式 Flash顺序写入是最快的。如果有多个消费者并发写磁头或 Flash 主控可能需要跳来跳去性能反而会下降。一个消费者线程维护一个文件句柄逻辑清晰不容易出 Bug。因此对于日志落盘这种I/O密集型的任务单消费者是最高效的选择。还有的朋友可能会问你第一章明明写了异步日志快同步阻塞是致命问题怎么上面的架构图又说实现了线程同步呢这不是左脑攻击右脑吗啊这里确实比较绕我有时也得想好久才能想通。下面我们详细拆解一下在宏观层面业务流程是异步的这里的异步描述的是生产者和消费者之间的协作关系。同步日志存在缺陷业务线程说“我要写日志”然后就在那死等直到磁盘写完才走。这叫“同步阻塞”。异步日志业务线程说“我要写日志”把数据扔进缓冲区转身就走去干别的事了。写磁盘的事留给后台慢慢做。这叫“异步非阻塞”。因为业务线程不需要等 I/O所以我们称之为异步日志系统。这是性能大幅度提升的原因。在微观层面内存访问是需要同步的这里的同步描述的是两个线程同时访问同一块内存时的安全规则。如果业务线程正在往Buffer[0]写数据还没写完后台线程突然跑过来要把Buffer[5]读走。这就乱套了。我们需要一种机制让他们协调一下“我要写了你先别动”互斥锁“我写完了该你读了”条件变量。这种为了保证数据安全、不打架的协调机制在操作系统里叫线程同步。举一个生活中的例子来比喻一下我们把业务线程看作服务员把Ring buffer看作窗口的菜单钉固定菜单把日志线程看作厨师。这里先简单提一下为什么把Ring Buffer看作菜单钉因为我们的环形缓冲区Ring Buffer正是一个malloc得来的巨大数组这个数组大小是固定的正如菜单钉只有一个菜单只能按顺序一个一个插能插的菜单数量也是有限的这个后面还会详细介绍先回归主题。在这个场景下理解就容易多了。异步服务员写好菜单往吧台上一插转身就去招呼下一桌客人了**异步**他不需要站在厨房门口等着厨师把菜炒好同步。这就是效率高的原因。线程同步吧台只有一个菜单钉只有一个。如果服务员正在往上插菜单厨师正好伸手去拔菜单两人的手就会撞在一起疼。所以我们需要一个规则互斥锁当一个人手在接触菜单钉时另一个人必须在旁边等着。这就是线程同步并不是说我们要一起动作而是我们要协调好顺序不要打架。2.2 核心角色定义在这个模型中每个角色都有自己的职责生产者系统中的业务逻辑线程如传感器信息采集它调用log_push接口将格式化好的日志字符串写入缓冲区。如果缓冲区没满写入动作几乎是瞬时的如果满了可以选择丢弃或短暂等待这取决于策略。环形缓冲区一段固定大小的内存空间。作为“蓄水池”平滑业务线程的流量波峰。使用数组模拟环形队列避免频繁的内存申请与释放。消费者一个独立的后台守护线程。死循环检查缓冲区。有数据就取出写入文件write没数据就挂起wait节省 CPU 资源。虽然它写磁盘慢但它一直在后台默默工作不影响前台业务。2.3 为什么这样设计解耦这样设计可以让业务逻辑不再依赖磁盘 I/O 的速度。哪怕磁盘突然卡顿了 1 秒业务线程依然可以流畅运行只要缓冲区没满。削峰填谷在某一瞬间日志量可能瞬间爆发。缓冲区可以暂时兜住这些数据后台线程再慢慢消化。如果没有缓冲区这种瞬间的大量 I/O 请求可能会直接把系统拖垮。批量写入这里要先提一嘴本项目演示的是基础异步写入没有采用批量写入。如果在生产环境中配合批量写入攒够 4KB 再落盘性能还能进一步压榨这也是 300 倍差距的来源之一。感兴趣的朋友可以自己实现一下批量写入看看性能差距相比每条日志写一次有多大。在进阶优化中消费者可以一次积攒 4KB 数据再一次性写入磁盘进一步减少系统调用次数延长 Flash 寿命。3. 数据结构选型为何抛弃链表在设计日志缓冲区的存储结构时有两种数据结构可以供我们挑选链表动态增长理论上无限大。环形缓冲区基于定长数组循环使用。初学者往往喜欢用链表因为简单且不需要考虑内存占满的情况。但在高性能、高并发、长期运行的嵌入式/服务端场景下链表有着致命的缺陷。3.1 链表的三大缺陷日志系统一般是要长期运行的采用链表的日志系统在压测和长期运行下通常会暴露下面几个问题3.1.1 内存碎片化每当有新日志产生都需要调用malloc分配一个小节点消费后调用 free释放内存。但在高频日志场景下如每秒 1000 次频繁的申请释放会将堆内存碎片化。在嵌入式设备长期运行的条件下7x24小时极易导致OOM (Out Of Memory)即使剩余总内存足够却申请不到连续的大块内存。这张图片应该是比较形象的大家看到这张图片应该能够明白内存碎片化大概是什么意思了。3.1.2 内存分配的性能开销malloc和free并不是简单的 C 语句它们是系统库函数。它们内部维护着复杂的内存链表并且为了线程安全malloc 内部通常也有锁。并且在内存碎片化后malloc调用产生的开销会越来越大因为内存上的孔洞越来越多这使得寻找一块合适大小的内存越来越困难。高频调用malloc本身就会消耗大量的 CPU 时间这已经违背了我们极速写入的初衷。3.1.3 CPU 缓存对链表不友好有朋友就想问了“为什么CPU缓存对链表不友好呢难道它对数组就很友好吗”。是的他对数组确实友好但这并不是它歧视链表而是由CPU本身的特性决定的。众所周知数组的内存是连续的而链表的节点在堆内存中分散分布着遍历链表需要随机跳跃访问内存导致Cache Miss缓存未命中率极高严重拖慢 CPU 效率。并且CPU读取内存时会利用空间局部性将相邻的数据预取到L1/L2 Cache中因此数组就占据了天然的优势。这里有必要拓展一下Cache Miss用一句通俗点的话来讲现代 CPU 性能的极大程度上取决于数据离核心有多近而Cache Miss正是由于要访问的数据太远所导致的。下面要聊的是计算机体系结构中比较经典的内容可能会比较枯燥但我会尽力把它描述的形象一点3.1.3.1 Cache的层级结构通常我们说L1L2L3三层缓存。L1 Cache一级缓存离 CPU 核心最近就在核心内部。速度最快但内存最小大小为32或64KB速度约1纳秒。L2 Cache二级缓存稍微远一点通常也是每个核心独享。比 L1 慢一点但内存更大256KB 或 512KB。速度约3-10 纳秒。L3 Cache三级缓存所有 CPU 核心共享的。更大但更慢几 MB 到几十 MB速度约10-20 纳秒。内存主板上的内存条。巨大但极慢速度约60-100 纳秒。这张图是我电脑的配置可以看到它是16核的。L1缓存为1MB平摊到每个核上面就是64KB/核。L2缓存平摊到每个核上面是1MB/核。L3缓存的64MB是所有16个核心共享的。这个配置看起来比上面介绍的参数要大一点实际上大差不差但是做嵌入式开发时面对的板子可能根本就没有L2和L3缓存。Cache Miss的代价会直接反应在设备卡顿上。3.1.3.2 什么是 Cache Miss先介绍一下什么是Cache Line缓存行如果CPU要访问一个字节系统不会只给它这一个字节而是把这个字节所在的Cache Line全部给它这个Cache Line通常是64字节。这意味着当读取内存地址 0x1000 时CPU 会自动把 0x1000 到 0x103F 的 64 字节全部加载到 Cache 中。每一次查找失败都叫一次 Miss。当CPU要读取一个变量时它会先查L1如果L1命中就直接使用耗时1ns。如果L1未命中就查L2。如果L2命中把数据搬到L1再给CPU耗时5ns。如果L2未命中就查L3。如果L3命中把数据搬到L2再搬到L1再给CPU耗时15ns。如果L3也查不到那就悲剧了。此时CPU只能挂起等待让内存控制器去RAM里面找这一等可能就是上百纳秒。对于 2GHz 的 CPU 来说100ns 意味着它可以空转200 个周期。这段时间 CPU 什么都干不了纯浪费。对于数组数组在内存里是连续存放的。当CPU要访问数组第一个元素大概率是不在三层缓存的他可能要等很久数据才能被拿过来速度当然就比较慢了这叫Cache Miss。但它拿回来的是数组第一个元素所在的Cache Line共64个字节。当他处理完第一个元素要处理第二个时哎它发现第二个元素就在他手边它可以很快的处理完第二个元素这叫Cache Hit缓存命中。并且后面它要处理的元素可能全都在它手边也就是说后面可能全部Cache Hit这样效率是极高的。对于链表链表的结点是malloc出来的在堆内存里面的分布是没有规矩可言的。这时CPU要访问每一个结点而这些结点存在于三层缓存的概率很小并且链表没有像数组那样一次访问慢但对后面多次访问效率提升具有促进作用的机制。因此如果使用链表Cache Miss的概率极高。3.1.3.3 一点小补充在高端嵌入式 SoC如树莓派、手机 Cortex-A中通常有 L1/L2/L3 三级缓存。但在低端单片机如 STM32 Cortex-M3/M4中通常没有 Cache或者只有简单的指令/数据缓存。尽管如此**RAM 的突发读取Burst Read**特性依然使得顺序访问数组比随机访问链表快得多。现代 RAM 的 Burst Read 特性让顺序读写 64 字节和随机读 1 字节的硬件成本几乎一样。 配合 CPU 硬件预取器和多级缓存真正连续、64 字节对齐的顺序访问Ring Buffer可以比随机跳跃的链表快 1050 倍而且这不是软件优化是物理底层的硬加速没得选。所以Ring Buffer 快不是因为代码写得好而是因为它吃到了 CPU Cache DRAM 三者联合的红利。 链表再优雅也打不过物理定律。3.2 环形缓冲区设计原理环形缓冲区本质上是一个固定大小的数组通过维护head读指针和tail写指针两个索引和取模运算来实现首尾相接的循环的效果。下面介绍一下实现的核心逻辑初始化申请一块大内存例如 1MB这显然不会造成内存碎片化。给head和tail赋0 。写入 (Push)数据存入buffer[tail]然后tail向后移动。然后tail (tail 1) % capacity读取 (Pop)从buffer[head]取出数据然后head向后移动。然后head (head 1) % capacity判断是否空或者满通过一个count计数器或者通过head和tail的相对位置来判断。在本项目中为了简化多线程下的状态判断我引入了一个count计数器配合互斥锁来精确控制。小知识在极度追求性能的场景下如 Linux 内核 kfifo通常会将缓冲区大小设置为2 的幂次方如 1024, 4096。这样可以用位运算 ()代替取模运算 (%)取模tail (tail 1) % 1024对应的位运算tail (tail 1) (1024 - 1)3.3 核心数据结构定义为了实现通用性与封装性我定义了下面结构体// 单条日志的最大长度 #define LOG_MSG_SIZE 128 // 日志 typedef struct { char data[LOG_MSG_SIZE]; } Log; // 环形缓冲区管理 typedef struct { Log *entries; // 指向 malloc 的大数组 int capacity; // 缓冲区总容量 int count; // 当前已存储的日志数量 int head; // 读索引 (Consumer 用) int tail; // 写索引 (Producer 用) // 用于优雅退出 int is_running; // 1: 正常运行 0: 停止 // 线程同步工具 pthread_mutex_t mutex; pthread_cond_t cond_producer; // 若缓冲区满 生产者睡觉 pthread_cond_t cond_consumer; // 若缓冲区空 消费者睡觉 //存放日志内容的文件的文件描述符 int file_fd; } RingBuffer;这种设计即保证了内存的连续性又通过互斥锁和条件变量保证了线程安全。在程序启动时rb_init一次性分配内存运行过程中再无内存申请操作极其稳定。这里的文件描述符需要再提一嘴初始化的时候我会把它置为1代表stdout也就是说默认的日志输出位置是终端。后面如果要打开文件时那就给他再次赋值。如果只是用来测试查看终端内容因而不进行对文件描述符重新赋值的操作那就要在close之前进行判断如果文件描述符的值为1就不进行close操作。但是为了保险起见我建议不论会不会对文件描述符重新赋值在关闭文件描述符之前都检查一下它的值。4. 核心代码实现为了保证代码的健壮性与可维护性我将核心逻辑封装在 ring_buffer.c 中对外提供简洁的 API。以下是几个最关键的实现细节。4.1 初始化 —— 一切的起点在初始化阶段我们需要申请内存池并初始化所有的同步工具锁与条件变量。这里有一个设计细节也就是上文提到的我们将file_fd默认初始化为STDOUT_FILENO(1)这意味着默认情况下日志会输出到终端方便调试。void rb_init(RingBuffer *rb, int capacity) { //一次性申请堆内存 rb-entries (Log *)malloc(sizeof(Log) * capacity); if (rb-entries NULL) { perror(malloc failed); exit(1); } //初始化状态 rb-capacity capacity; rb-head 0; rb-tail 0; rb-count 0; rb-is_running 1; //标记系统运行中 //默认输出到终端 rb-file_fd STDOUT_FILENO; //初始化互斥锁和条件变量 pthread_mutex_init((rb-mutex), NULL); pthread_cond_init((rb-cond_producer), NULL); pthread_cond_init((rb-cond_consumer), NULL); }这个初始化函数的内存分配正是结合了我们前面一次性申请一大块连续内存的思路在加快访问速度的基础上还一定程度上保护了Flash。默认情况下is_running是为1的从这个成员的名字很容易就能看出它为1是是正常工作的。后面在main函数成功回收全部生产者时将这个值置为0在消费者中检测这个值为0后执行相应的退出程序防止生产者已经全部退出了消费者还在苦苦等待日志作无用功这种情况发生。日志内容的默认输出位置就没必要再讲了这里只是用宏代替了1直接用1也是可以的除了可读性下降没有什么坏处。最后就是初始化锁和条件变量了这里我设计了两个条件变量从名字来看一个是属于生产者的另一个是属于消费者的。若缓冲区为空则消费者睡觉直到生产者生产出日志内容会用cond_consumer来叫醒消费者。若缓冲区已满则生产者睡觉当消费者消费日志内容使得缓冲区空间空出来时他会使用cond_producer叫醒生产者。这在后面的具体代码实现中会直观的看到。4.2 生产者 (Push)拒绝虚假唤醒这是高并发写的核心逻辑。为了防止“惊群效应”和“虚假唤醒”必须严格遵守 POSIX 多线程编程规范。void rb_push(RingBuffer *rb, const Log *entry) { pthread_mutex_lock((rb-mutex)); while (rb-count rb-capacity) { pthread_cond_wait((rb-cond_producer), (rb-mutex)); } // 直接 memcpy 到数组对应位置 memcpy((rb-entries[rb-tail]), entry, sizeof(Log)); // 更新索引 rb-tail (rb-tail 1) % rb-capacity; rb-count; // 唤醒消费者 pthread_cond_signal((rb-cond_consumer)); pthread_mutex_unlock((rb-mutex)); }Push这个单词很形象我相信不解释大家也能知道这里在干什么这个函数就是用来把日志内容Push进RingBuffer的。这里的判断为什么要使用while呢可能会有些喜欢思考的朋友认真看了之后发现这里只是判断一下缓冲区内的日志数量是否等于缓冲区最大容量也就是判断缓冲区满了没有。如果满了那就等呗没满那就继续生产日志呗。那按道理来说用if来判断也行啊其实是不行的这里存在一个虚假唤醒的情况。虚假唤醒是指线程在调用pthread_cond_wait()开始睡觉之后即使没有任何人调用pthread_cond_signal()或者pthread_cond_broadcast()线程也有可能莫名其妙的醒来。但这个看似存在漏洞的行为其实是 POSIX 标准明确允许的行为。导致虚假唤醒比较常见的原因有操作系统调度器的内部优化内存伪共享信号中断等。所以说醒来并不一定是被signal或者broadcast唤醒的意思就是条件不一定能满足。大家现在来想象一下这个场景假如线程由于虚假唤醒醒来了但实际上这时缓冲区还是满的它应该睡觉不应该醒着。如果使用了if来判断它会什么都不管直接继续往下执行先把entry的内容拷贝到RingBuffer然后更新索引和日志条数然后就完蛋了(╯°□°)╯︵ ┻━┻。因为缓冲区已经溢出了。但是如果这里使用while进行判断在触发虚假唤醒后他会重新检查缓冲区是否满了如果没满就跳出while继续往下执行。如果满了那就在执行pthread_cond_wait((rb-cond_producer), (rb-mutex));进入睡眠。不会发生像if那样的可怕场景。4.3 消费者 (Pop) 与优雅退出消费者不仅要处理数据还要负责监听停机指令。如果只是一个 demo 那让消费者直接无限循环就完事了后面直接 CtrlC 暴力终止。但是如果想真正应用到工程上那就要实现优雅退出。// 函数返回值1 表示成功拿到数据0 表示系统关闭且无数据intrb_pop(RingBuffer*rb,Log*entry){pthread_mutex_lock((rb-mutex));// 只有当缓冲区为空且系统还在运行时才选择等待while(rb-count0rb-is_running){pthread_cond_wait((rb-cond_consumer),(rb-mutex));}// 如果醒来发现没数据了而且系统标志位已关闭那就解锁后退出if(rb-count0!rb-is_running){pthread_mutex_unlock((rb-mutex));return0;}// 正常消费memcpy(entry,(rb-entries[rb-head]),sizeof(Log));rb-head(rb-head1)%rb-capacity;rb-count--;// 唤醒生产者pthread_cond_signal((rb-cond_producer));pthread_mutex_unlock((rb-mutex));return1;}代码中已经有了比较详细的注释但为了内容的全面性我还是简单介绍一下这个函数。先来看while循环只有当缓冲区为空而且系统还在运行时才等待。如果缓冲区为空但is_running被置为0了那就跳出循环往下执行到if条件满足解锁后直接返回0后面在消费者线程中会检查这个返回值这个到后面再说吧。如果缓冲区不为空且is_running为1正常运行那就是正常消费后唤醒生产者函数返回1。如果缓冲区不为空且is_running为0也就是说生产者已经全部退出了但缓冲区还有些日志内容没有被消费。那我们的逻辑就是消费完这些日志再退出。来看代码这种情况依然会跳出while循环在if判断是是不满足条件的所以不会在if里面退出而是继续往下执行正常消费日志完了返回1到后面大家会知道消费者线程是在循环调用这个函数的所以会一直重复上面描述的过程直到日志全部被消费完缓冲区空了这时满足if的条件了返回0。消费者线程检测到0后优雅退出。这里我还想提一点我在 4.2 和 本节4.3 里都用了memcpy。在这里使用memcpy是安全的因为我们在log_push里已经用snprintf保证了源数据长度不会超过LOG_MSG_SIZE所以不会发生越界拷贝。优雅退出的代码没什么深度我就不解释了直接放在下面//优雅退出 void rb_stop(RingBuffer *rb) { pthread_mutex_lock((rb-mutex)); rb-is_running false; pthread_cond_broadcast((rb-cond_consumer));//唤醒消费者 pthread_mutex_unlock((rb-mutex)); }4.4 安全性封装拒绝缓冲区溢出在 C 语言中strncpy和sprintf都是缓冲区溢出的重灾区。为了保证日志内容的安全我在业务封装层使用了snprintf。snprintf是 C 语言中最重要最安全的字符串格式化函数之一它几乎完全取代了老的sprintf也是现代 C 代码中唯一推荐使用的格式化输出到字符串的函数。MISRA C 2012汽车嵌入式规范禁止使用库函数sprintfvsprintf必须使用带长度限制的替代函数如snprintf、vsnprintf。Linux 内核、GNU、LLVM、Chrome、Firefox 等大型项目全部禁止sprintf强制snprintf。除了一些大型项目可能还存在历史遗留的sprintf()调用问题。在现在的新项目中绝对应该使用snprintf而不是sprintf这是工业界的共识不是可有可无的风格问题而是安全问题。void log_push(RingBuffer *rb, const char *msg) { Log log_entry {0}; // 可以自动处理 \0 结尾防止 msg 过长导致的越界访问 snprintf(log_entry.data, LOG_MSG_SIZE, %s, msg); rb_push(rb, log_entry); }5. 性能压测与总结为了验证这个异步日志库的健壮性我设计了一个高并发 极小缓冲区的测试场景。5.1 压测场景设计为了模拟最恶劣的竞争环境我特意将缓冲区容量设置得极小强迫线程频繁发生阻塞和唤醒。生产者2个线程Producer1Producer2每个狂发1000条日志。消费者1个后台线程。缓冲区容量设为10这意味着生产者每生产几条日志就要被迫睡觉锁竞争将会非常激烈。预期结果日志文件应该包含整整2000条数据无丢失无乱序线程内部有序并且程序可以优雅退出。这是 main.c 的内容大家可以照着我上面的解释梳理一下逻辑:#include stdio.h #include stdlib.h #include string.h #include pthread.h #include unistd.h #include sys/types.h #include sys/stat.h #include fcntl.h #include ring_buffer.h #define FILE_NAME log.txt void log_push(RingBuffer *rb,const char *msg) { Log log_entry {0}; snprintf(log_entry.data,LOG_MSG_SIZE,%s,msg);//最推荐的函数 rb_push(rb,log_entry); } void *pthread_producer1(void *arg) { RingBuffer *rb (RingBuffer *)arg; for(int i0;i1000;i) { char buf[64]; snprintf(buf, sizeof(buf), Producer1 code:%d\n, i); printf(Producer1 pushing:%s\n,buf); log_push(rb,buf); usleep(100); } return NULL; } void *pthread_producer2(void *arg) { RingBuffer *rb (RingBuffer *)arg; for(int i0;i1000;i) { char buf[64]; snprintf(buf, sizeof(buf), Producer2 code:%d\n, i); printf(Producer2 pushing:%s\n,buf); log_push(rb,buf); usleep(100); } return NULL; } void *pthread_consumer(void *arg) { RingBuffer *rb (RingBuffer *)arg; Log entry; while(rb_pop(rb,entry))//循环检测返回值看看是不是要退出 { write(rb-file_fd,entry.data,strlen(entry.data)); } printf(消费者线程优雅退出\n); return NULL; } int main() { RingBuffer rb; rb_init(rb,10); rb.file_fd open(FILE_NAME,O_RDWR|O_CREAT|O_APPEND,0644); if(rb.file_fd -1) { perror(文件打开失败); return -1; } pthread_t consumer,producer1,producer2; pthread_create(consumer,NULL,pthread_consumer,(void *)rb); pthread_create(producer1,NULL,pthread_producer1,(void *)rb); pthread_create(producer2,NULL,pthread_producer2,(void *)rb); pthread_join(producer1,NULL); pthread_join(producer2,NULL); printf(所有生产者已经结束任务\n); rb_stop(rb); pthread_join(consumer,NULL); printf(消费者已经退出\n); close(rb.file_fd); rb_destroy(rb); return 0; }由于篇幅已经比较长了下面我直接贴出 Makefile CC gcc CFLAGS -Wall -g -pthread TARGET app SRCS main.c ring_buffer.c OBJS $(SRCS:.c.o) all: $(TARGET) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $(TARGET) $(OBJS) %.o: %.c ring_buffer.h $(CC) $(CFLAGS) -c $ -o $ .PHONY: clean test clean: rm -f $(OBJS) $(TARGET) log.txt test: $(TARGET) ./$(TARGET)简单介绍一下怎么使用首先命令行输入make进行编译然后输入./app运行程序。或者嫌麻烦的可以直接命令行输入make test这会直接编译并且运行程序。运行完成后可以使用make clean清除程序运行产生的文件。5.2 测试结果验证运行之后终端输出如下由于我在两个生产者中都添加了printf函数所以在终端会看到他们打印出来的内容总共2000条我只截图了最前面和最后面的部分可以看到已经实现了优雅退出生产者全部退出后消费者自动退出。然后我们再来查看一下日志内容是否存放到了 log.txt 里面我们同样只截去最前面和最后面的部分两个生产者线程产生的日志内容都是从0-999也就是各1000个总共2000个。再来查看 log.txt 文件的行数发现也是2000行。这足以说明一条日志都没有丢失。结果分析在容量仅为 10 的情况下2000 条日志全部落盘证明条件变量的waitsignal逻辑很严密。主线程成功等待所有子线程结束证明优雅退出机制生效没有线程卡在wait状态。从终端输出可以看到两个生产者交替抢占时间片证明互斥锁有效保护了临界区。5.3 项目总结与思考从最开始简单的每产生一条日志就用write直接写盘到最终完成这个异步日志库这个过程不仅仅是代码量的增加更是工程思维的提升。下面是我在整个过程中的一些思考5.3.1 向硬件物理特性妥协有人可能更关注算法的时间复杂度但在嵌入式底层开发中硬件特性才是性能的天花板。放弃链表选择Ring Buffer不仅仅是为了管理方便更是为了确定性。在长期运行的嵌入式系统中避免频繁malloc/free造成的堆内存碎片是系统稳定的基石。利用数组的空间局部性大幅减少Cache Miss让 CPU 流水线跑得更顺畅。5.3.2 解耦与削峰填谷利用 **生产者-消费者模型 **将业务逻辑快与磁盘 I/O慢彻底分离。Ring Buffer在这里扮演了“蓄水池”的角色。面对突发流量缓冲区能起到削峰填谷的作用防止瞬间的高频 I/O 请求将系统拖垮。虽然使用了互斥锁但配合条件变量避免了忙等待在保证数据安全的同时最大程度降低了 CPU 占用率。5.3.3 防御性编程与生命周期全面禁用strcpy/sprintf严格使用snprintf防止缓冲区溢出。实现了优雅退出机制。通过is_running标志位和广播唤醒确保程序退出时缓冲区内残留的日志能被全部消费做到数据零丢失。通过结构体封装文件描述符与互斥锁和条件变量实现了高内聚低耦合拒绝全局变量的污染。5.4 进阶优化方向虽然目前的版本已经是一个稳定、高效的异步日志库但在面对更苛刻的工业级场景如车规级嵌入式、高频交易系统时我们还可以从以下几个维度进行深度优化5.4.1 批量写入与页缓存对齐前面已经提到批量写入的内容如果采用批量写入还能在本项目的基础上把性能进一步压榨。而目前的实现是消费者每次Pop一条日志就调用一次write。而write是系统调用涉及用户态到内核态的上下文切换存在着巨大的开销。优化思路在消费者线程内部维护一个4KB一页内存的中间缓冲区。当积攒的数据达到 4KB 或者超时可以自己设定一个时间时再一次性调用write落盘。这能将系统调用的频率降低一个数量级并更好地利用文件系统的Page Cache。5.4.2 存储管理嵌入式设备的存储空间Flash/SD卡是有限的我们不能让 log.txt 无限增长。优化思路当 log.txt 达到 10MB 时自动将其重命名为 log.txt.1并创建新的 log.txt。可以保留最近 N 个备份自动删除最旧的日志防止磁盘写满导致系统崩溃。5.4.3 无锁队列目前的实现使用了MutexCond。在极高并发几十个线程同时写入下锁竞争会导致 CPU 上下文切换开销增大。优化思路参考 Linux 内核kfifo的实现利用原子操作和内存屏障 ** 来管理 head 和 tail 指针。最终目标是实现单生产者-单消费者模式下的完全无锁或多生产者**模式下的无锁入队将性能压榨到物理极限。5.4.4 零拷贝当前的log_push涉及两次拷贝snprintf到栈memcpy到RingBuffer。优化思路设计函数接口允许业务线程直接在 RingBuffer 的内存区域内进行格式化写入通过返回写指针省去中间的栈内存拷贝过程进一步降低内存带宽占用。6. 项目源码点击跳转到 GitHub 仓库