除去音频处理耗时外,音频延迟主要由缓存引起。本文描述了Linux下如何减小音频缓存,并介绍该如何避免音频缓存欠载和过载。
一、如何减小音频缓存
- buffer是环形缓冲区,大小为period_size * period_count 。由于buffer可以设置得很大,如果要在一次中断中传输完整个buffer会引入过大的延迟,因此buffer被划分成多个period
- period是每个硬件中断之间传输的frame的个数,大小由period_size指定
- frame为同一时刻采集或播放的样本,如果是双声道,那么一个frame包含左右声道的sample
使用更小的buffer(调小period_size和period_count)可以缩短延迟,也更容易触发欠载和过载(在ALSA中统称为XRUN):
- 欠载指播放音频过程中应用送数据到buffer不及时,导致buffer中无数据可播放
- 过载指采集音频过程中应用从buffer取数据不及时,导致buffer循环缓冲区被覆盖
渲染音频对实时性要求非常高,需要在固定时间内将固定数量的音频数据传输到音频硬件。音频进程(本文并不区分进程与线程,对Linux调度器而言它们是等价的)要和系统中的其他进程竞争CPU资源,一旦音频进程没有在给定的时间内获得CPU时间片,音频缓冲区耗尽,用户会听到刺耳的爆破音,缓冲区越小越容易出现该问题。
二、如何避免欠载和过载
常见的欠载和过载的原因如下:
- 优先级过低
- 优先级反转
- 调度延迟
- 长时间中断禁用
- 内存管理
优先级过低
Linux进程按调度策略可分为普通进程和实时进程:
- 普通进程的调度策略包括:SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE
- 实时进程的调度策略包括:SCHED_DEADLINE、SCHED_FIFO、SCHED_RR
如图3所示,内核使用0到139表示内部优先级,值越低,优先级越高。0到99供实时进程使用,普通进程的nice值[-20,+19]映射到范围100到139,可见实时进程的优先级总是比普通进程高。
普通进程使用CFS(完全公平调度类)实现调度。CFS旨在公平地对待共享通用CPU资源的竞争性工作负载,进程nice值越小,优先级越高,分配的CPU时间越多,同时CFS要避免低优先级的进程饥饿,因此会出现较低nice值的进程的CPU被转移到较高nice值的进程的情况,对音频而言,这可能会导致欠载或过载。
与普通进程不同,调度程序总是让优先级高的实时进程运行,换句话说,实时进程运行的过程中禁止低优先级进程的执行。只有在下述事件之一发生时,实时进程才会被另一个进程取代:
- 进程被另外一个更高优先级的实时进程抢占
- 进程执行了阻塞操作并进入睡眠
- 进程停止或被杀死
- 进程调用sched_yield()自愿放弃CPU
- 进程事基于SCHED_RR(时间片轮转)的实时进程,而且用完了自己的时间片
为避免音频进程被抢占,可以使用SCHED_FIFO调度策略。当然使用SCHED_FIFO调度策略后,音频进程仍然会被其他优先级更高的SCHED_FIFO进程抢占,需要检查更高优先级的进程保证它们在有限时间内完成。
优先级反转
优先级反转出现过程如图4所示:
- 绿色进程(低优先级)先执行,获取信号量并执行临界区代码
- 红色进程(高优先级)进入就绪状态(ready state),调度器抢占绿色进程让红色进程执行
- 红色进程获取信号量阻塞
- 黄色进程(中优先级)进入就绪状态,由于黄色进程优先级比绿色进程高,且不需要获取信号量,因此它会先执行完
- 绿色进程执行完临界区代码后释放信号量
- 红色进程继续执行
在该例子中,高优先级进程要等待中优先级进程执行完后才能执行,因此被称作优先级反转。
避免优先级反转的一种方法是使用有优先级继承(priority inheritance)功能的互斥锁,假如图4的例子将信号量换为这种互斥锁,当红色进程获取互斥锁阻塞时,绿色进程的优先级会临时提高到和红色进程一样,这样黄色进程就无法抢占绿色进程,也就不会发生优先级反转。
Linux中的rt_mutex实现了优先级继承,可以在编译内核时通过配置宏CONFIG_RT_MUTEXES开启。
避免优先级反转的另一种思路是设计之初就避免访问临界区资源。譬如用无锁队列,如果有多个地方需要访问共享资源,可以考虑创建一个“server”负责直接操作共享资源并通过非阻塞的消息队列获取“client”操作共享资源的请求,而不是在每个访问的地方加锁。
调度延迟
调度延迟是指从线程已准备好运行到环境切换完成可以在 CPU 上实际运行之间的间隔时间。延迟越短越好,一旦超过 2 毫秒,就会造成音频问题。
非抢占内核中,如果系统处于核心态并正在处理系统调用,那么系统中的其他进程是无法夺取其CPU时间的。调度器必须等到系统调用执行结束,才能选择另一个进程执行。如果内核处于相对耗时较长的操作中,比如文件系统或内存管理相关的任务,这可能导致调度延迟增加。编译内核时打开CONFIG_PREEMPT选项可以启用对内核抢占的支持。开启内核抢占后,如果高优先级进程有事情需要完成,不仅用户空间应用程序可以被中断,内核也可以被中断。
CONFIG_PREEMPT在一些决策上为了兼顾吞吐量仍然会牺牲延迟指标,而 CONFIG_PREEMPT_RT(Linux实时内核补丁)则是为追求低延迟设计,它实现了完全可抢占特性,譬如CONFIG_PREEMPT只能抢占系统调用,无法抢占中断,而CONFIG_PREEMPT_RT将中断处理程序转换为可抢占的内核线程。在ARM处理器上的测试显示CONFIG_PREEMPT_RT的最大中断响应时间比CONFIG_PREEMPT少了37%~72%。
降低调度延迟的另一种方法是通过isolcpus或cpuset让音频进程在被隔离的CPU上运行,这样能保证该CPU上不会有其他的用户进程执行。
长时间中断禁用
如果某个中断处理程序运行时间较长,或者中断被长时间禁用,那么音频直接内存访问(DMA)的中断会被延迟,有可能导致XRUN。为避免中断处理程序运行时间过长,大部分设备驱动程序分为两部分,一部分是中断处理程序,它被设计为快速完成,一般只是拷贝数据到设备指定位置,另一部分在内核线程中完成剩余的工作。
内存管理
Malloc申请内存的耗时是不受限制的,因此音频进程应该预先申请内存而不是在处理过程中申请。由于请求分页机制的存在,申请的虚拟内存并不会马上被映射到物理内存,而是要延迟到程序第一次访问这些虚拟内存的时候,类似地,程序也只是被访问到的部分被加载到了物理内存。调用mlockall()接口可以确保程序被加载到了物理内存,并且避免虚拟内存被换出到交换文件,但是malloc申请的内存仍然不会被马上映射到物理内存,为了确保malloc申请的内存映射到物理内存,进程应该在申请完内存后马上写一遍内存。
最后总结下避免欠载和过载的方法:
- 音频进程使用SCHED_FIFO调度策略,并设置合理的优先级
- 使用无锁队列等方法避免访问临界区资源
- 打开CONFIG_PREEMPT选项启用内核抢占
- 使用CONFIG_PREEMPT_RT Linux实时内核补丁
- 通过isolcpus或cpuset让音频进程在被隔离的CPU上运行
- 调用mlockall()接口确保程序被加载到了物理内存,并且避免虚拟内存被换出到交换文件
- 预先申请内存,并申请完内存后马上写一遍内存
参考文章
音频延迟的促成因素
音频开发中常见的四个错误
Introduction to Sound Programming with ALSA
RTOS debugging, part 4: Priority inversion ? when the important stuff has to wait