以下内容主要为音频开发人员所编写,但同样也能为其他领域并与此相关的开发者带来帮助。在下文当中我将介绍针对开发人员的诊断工具,并分享常见的四个错误以及如何检测问题是否存在并做得更好。
文 / Michael Tyson
译 / John
制作音频产品是一项极富创造性的事业,如果你是音频开发者,那么你开发的产品将为更多相关行业人员带来创作上的帮助,这不能不说是一件颇具成就感的事业。
但与此同时这也意味着每一位音频开发者肩上的担子非常沉重,他们需要承担确保产品服务稳定可靠的责任。一个DJ设备出现不必要的噪声,这对使用者与开发者来说都是不愿意看到的。而现在我们处于一个跨设备协同大行其道的时代,由于流程的复杂,出现问题时寻找问题的根源往往会成为一件十分麻烦的事情。
错误难以避免
电视节目《The Tonight Show》的音频工程师告诉我,他们选择Loopy的主要原因是他们多年以来一直是Loopy的用户,Loopy是值得信赖的。
即使一个应用程序在某一环节出现故障,其概率也为千分之一。但一旦长时间不间断地使用设备,出现故障的可能性就会大大提升,而且故障出现的频率也会大大增加。
尤其是在现场直播活动当中,一点小小的故障都能让台上正在表演的艺术家信心崩溃,而直播也意味着技术人员无法在演播过程中打断直播修理调整,这无疑需要整个团队冒着巨大的的风险。
因此在下文中,我们将重点关注音频开发人员需要尽到的义务,因为我们需要确保所开发的应用程序在所有时间都稳定可靠。
每个人都不是完美的,漏洞能够减少但无法绝对避免,因此下文我不会站在某个制高点去单纯地指示大家该怎么做。我在 Audiobus和 The Amazing Audio Engine上的工作经历使得我更加倾向于从代码开发的角度阐述这些命题。
然而现实往往是残酷的,问题远比我想象的要复杂,甚至许多高知名度的库也违反了多条规则,以至于最近我不得不赶忙修复Loopy中的一些故障。事实证明,这些故障大多是由第三方库(不是音频引擎,而是其他东西)在执行不当操作时引起的。
以下是我想要强调的四项容易出现的错误:
1. 不要在音频线程上坚守“锁(locks)”。
例如:pthread_mutex_lock 或 @synchronized。
2. 不要在音频线程上使用Objective-C / Swift语言。
例如:[myInstancedoAThing] 或 myInstance.something。
3. 不要在音频线程上分配内存。
例如:malloc()、new Abcd 或 [MyClass alloc]。
4. 不要在音频线程上执行文件或网络I/O。
例如:read、write或sendto。
尽管以上内容看上去并无关联,但违反上述准则中的任何一个都可能会让你的产品出现很严重的问题,尤其是当使用第三方库的时候。这里需要强调的是,音频系统容易出现的故障还有很多,例如逻辑错误或者只是要求太多的设备功能,但是以上四个问题属于比较容易发现且被解决的。违反这些规则可能导致一些无关痛痒的错误,也可能将整个音频系统推向崩溃的边缘,那么究竟是什么原因导致这一切的发生呢?
执行任何音频应用程序都至少需要运行两个线程:主线程和音频线程。通常还需要执行其他线程例如网络线程或用于处理UI的线程。
这些线程与当前正在运行的其他所有应用程序线程会共享CPU这一有限的运算资源:
而渲染实时音频的性能要求非常高:每n秒系统就需要将n秒的音频数据传输到音频硬件。否则,缓冲区将耗尽,用户会听到讨厌的毛刺或爆音声——音频播放到完全静音之间的粗暴过渡。
音频线程需要定期进行维护,并且需要在非常短的时间限制内完成该任务,一般仅需要几毫秒或更短的时间。这是一个实时线程,其具备一定的权限。如果UI中发生了一些异常(上方的蓝色线程)或者有网络操作(橙色线程)正在运行,同时CPU也在渲染一些音频,那么CPU 会丢弃所有内容使得有足够的算力服务于音频线程——这是CPU当前需要处理的头等大事。
接下来才是真正麻烦的地方
要知道,我们今天所使用的流行操作系统都不是真正的“实时”操作系统。他们采取“尽力而为”的策略,尽力满足用户对于算力的需求但却无法达到最佳效果。这就意味着一些超出计算机能力范围的任务可能会导致音频线程中断并白白浪费时间。
因此,我们的目标是最大程度地减少该问题发生的可能性并降低风险。
如果您在音频线程上运行的代码中违反了上述规则之一,则会发生一些尴尬的事情。假设我们有一些代码使用与主线程共享的数据结构,例如是一个简单的播放音符的列表,我们期待的是响应用户按下按钮以在该列表中添加和删除音符的操作:
代码语言:javascript复制// Define some types
struct Note {
int noteId;
float frequency;
float velocity;
uint64_t startTime;
};
struct NoteList {
int noteCount;
struct Note notes[1]; // noteCount-1 Notesfollow
};
struct NoteList * __noteList;
...
// Functions to add and remove notes
-(int)addNoteWithFrequency:(float)frequency
velocity:(float)velocityatTime:(uint64_t)startTime;
- (void)removeNoteWithId:(int)noteId;
我们可以编写一些可接受该列表并根据其中的内容生成音频的音频渲染代码。但是这一过程会使用于主线程和音频线程之间共享的计算资源。这些线程可以中断甚至同时运行,所以我们可能会遇到这样的情况:音频线程在与主线程编辑数据的同时读取数据,从而导致进程崩溃或数据损坏。
解决这些并发问题的常用方法是使用“锁(locks)”(也称为互斥锁或互斥对象),也就是一次只允许一个线程通过:当我们要与共享的数据结构进行交互时,我们将查看它是否已被锁定;如果是这样那么我们需要待其解锁之后将其锁定,直至完成该进程后再将其解锁。从而避免了并发访问的情况。
继续刚才的示例,我们将在此处以及操作列表的函数中使用互斥锁保护进程:
代码语言:javascript复制pthread_mutex_t __noteListMutex;
void MyAudioRenderFunction() {
// Lock it up
pthread_mutex_lock(__noteListMutex);
// Make noise
for ( int i=0;i<__noteList->noteCount; i ) {
ProduceAudioForNote(&__noteList->notes[i]);
}
// Okay, we're done, unlock
pthread_mutex_unlock(__noteListMutex);
}
如果主线程当前正在更新列表,而此时在音频线程上使用pthread_mutex_lock时会发生什么?CPU将阻塞音频线程,并放弃该线程,转而使用另一个不受阻塞的线程。如果我们花太长时间无法完成主线程上的列表更新,那么…
随着时间的流逝,音频系统出现了故障。看起来和听起来就像这样:
如果我们将列表更新代码替换为运行速度很快的部分,那么我们只需短时保持住锁的效果,就可以了吗?
答案远非如此。一些开发人员认为,只要不长时间持有锁就可以了,实际上他们错了。
还记得上图中的其他黄色线程吗?这些黄色进程的优先级比主线程高一点,也许是我们的应用程序正在做一些与MIDI相关的工作;也许它正在执行一些对时间要求严格的脱机处理或某些网络通信……无论如何,这些操作都可能需要更高的优先级。
多线程所面对的一项问题是,它超出了我们的控制范围。调度程序(一种引导CPU注意力的“神秘野兽”)可以随时中断线程,并将CPU时间分配给更多需要它的线程;除此之外,调度程序还需要将CPU分配给其他正在运行的应用程序中的其他线程。
此时此刻,进程的情况是:
因为我们的辅助线程(黄色)的优先级高于我们的主线程(蓝色),所以调度程序会从音频线程正在等待的主线程上窃取CPU时间。这一过程被称为“优先级倒置”。
通过将把主线程上保持锁定的时间最小化,我们可以降低发生这种情况的可能性,但实际上这一问题并未完全消除。
我们的应用每天需要处理上千个用户的会话,将其与Audiobus或IAA多应用程序环境结合使用会大大提升整个系统崩溃的风险。哪怕在一个典型的会话中有千分之一的机会出现bug,但如果我们的应用每天处理一万个会话,就意味着bug无时无刻不发生。
摒弃了锁,那么Objective-C或Swift又有什么问题?
如果仅使用Obj-C / Swift渲染音频那么这会非常方便——无论是传递对象还是继承等都可以实现,除此之外许多第三方音频库也可以做到这一点,那么问题出在哪里?
问题的关键在于:Objective-C和Swift持有锁是其正常操作的一部分。
在Objective-C的消息发送系统(即调用Obj-C方法)的背后,是一系列包括持有锁在内的完成工作所需的必要代码。
(相关源代码可访问:opensource.apple.com:[objc_msgSend](https://opensource.apple.com/source/objc4/objc4-680/runtime/Messengers.subproj/objc-msg-arm64.s) [__class_lookupMethodAndLoadCache3](https://opensource.apple.com/source/objc4/objc4-680/runtime/objc-runtime-new.mm) 以及lookUpImpOrForward lock(runtimeLock)和lock(cacheUpdateLock)。)
顺便说一句,通过点语法(myInstance.property)访问属性也算作一个Objective-C方法调用,因此这也是不可行的。
实际上,我们甚至不能允许ARC保留Objective-C或Swift对象,因为该保留机制也持有一个锁(可参阅:[sidetable_retain](https://opensource.apple.com/source/objc4/objc4-680/runtime/NSObject.mm)中的table.trylock()以及sidetable_retain_slow和table.lock()。
分配内存又存在什么问题?
Malloc和其相似的一系列用来分配内存以供进程使用的函数,其分配内存的执行时间不受限制,这意味着整个过程可能比可能需要花费更长的时间,其造成的后果与优先级倒置相似。
遗憾的是,这里我无法提供明确的代码示例以帮助你了解此项问题。而伴随着无限的执行时间,malloc还使用了一个锁。
文件和网络IO也是如此
所有的I/O功能——read,fread,fgets,write,send,sendtorecv、recvfrom等也有无限的执行时间,其需要在辅助线程上进行。
那么libdispatch和正在使用的块呢?
不幸的是,这些也是禁区。尽管您可以安全地在音频线程上调用一个块,只要不在其中保留或释放它。在音频线程上创建一个块会导致一些内存分配以及一些对象的保留,同时这两个对象都将持有锁。
那么,该怎么办?
就像我之前说的,我们所使用的绝大多数操作系统都不是真正的实时操作系统,这意味着操作系统本身无法保证实时性可以得到有效落实。因此,我们所追求的是最大程度地减少遇到麻烦的机会。
好消息是:这里有很多工具可以帮助您解决此问题,同时也有一些非常容易遵循的模式。
首先,Objective-C围绕C构建,并且实际上可以像C结构一样从implementation块中的C函数访问Objective-C,示例如下:
代码语言:javascript复制FFCrewMember * jayne;
...
jayne->location = FFLocationBunk;
这是一个普通的取消引用旧指针,没有Objective-C来持有锁或其他任何东西,故而在实时线程上使用它是绝对安全的。
因此,您仍然可以绕过并使用对象。但是就像我之前说的,您需要避免任何保留。除此之外,在声明一个Objective-C实例变量时我们只需要使用该__unsafe_unretained属性来绕过任何ARC内容:
代码语言:javascript复制void MyCFunction(__unsafe_unretainedFFFertileLand * thisLand) {
__unsafe_unretained FFFertileLand *yourGrave = thisLand;
}
小菜一碟,是吗?
需要注意的是:在寻求其他专家的验证时,Tempo Rubato的RolfWöhrmann(NLog,Nave,iSEM)建议禁止从音频代码中引用对Objective-C或Swift对象的任何引用,即使其具有该__unsafe_unretained属性,仅仅是传入C或C 变量也不能进行。他主张将两者完全分开。当然,这是最安全的选择。这是一个非常防御的策略。
跨线程同步呢?我们如何更换锁?
这里有很多可能性。苹果提供了许多非常有用的内部组件如libkern/OSAtomic.hheader,还有OSAtomicEnqueue和OSAtomicDequeue,OSAtomicAdd32Barrier以及OSMemoryBarrier。如果不知道该怎么办,使用trylock(例如pthread_mutex_trylock)也是可行的选择。
在所有的现代处理器上,你可以安全赋值给一个int,double,float,bool,BOOL或在一个线程中的指针变量并读取其不同的线程而不用担心线程被打断。其中只有部分值已在读取时被分配,这是因为字节,半字和字长的分配是atomic的(http://ds.michael.tyson.id.au/qv8jv8mtfC/ armv7-a-r-manual-A3.5.4.pdf) (《ARM®体系结构参考手册》 ARMv7-A和ARMv7-R版),只要该变量是自然对齐的(如果它是Objective-C实例变量)就可存在于未打包的结构中。注意,这不一定适用于其他类型的变量。如果您使用的是32位处理器,并且分配了一个uint64_t 变量,您可能会遇到麻烦,因为处理器需要两条单独的指令来存储值,而另一个线程可以在读取过程中途读取该值。如果你不想被乱成一团的头绪所影响,其实有解决方案可供你使用。
以下是我自己研制的一些解决方案:
[TPCircularBuffer(https://github.com/michaeltyson/TPCircularBuffer) 是一个被广泛使用的循环缓冲区库,我 早在几年前就写过这一库并且至今仍每天使用它。你可以将数据从一个线程的一端粘到另一端,然后从另一线程中拉出它而无需持有任何锁,并且通过虚拟内存策略,您可以完全忽略“使用带wrap point的循环缓冲区”这一事实。它还使您可以读写AudioBufferLists(交错和非交错),并且还可以携带AudioTimestamp值,所有这些都使其可以与CoreAudio一起使用。其 被内置于 在AmazingAudio Engine 2中 作为AECircularBuffer。
来自 AmazingAudio Engine 2的 AEManagedValue提供了一个指针变量,该指针变量经过精心设计以使其分配过程可以达到atomic,并且仅在音频线程完成该值后才释放。也就是说,您可以使用它指向您喜欢的任何数据结构或Objective-C类,并且当您更改值时,仅在不会与音频线程混淆的情况下旧值才会被释放。
接下来我将在原有示例的基础上,借助AEManagedValue维护对NoteList指针的引用,并在更改列表时简单地重新分配列表:
代码语言:javascript复制@interface MyClass ()
@property (nonatomic, strong)AEManagedValue * noteList;
@end
@implementation MyClass
- (instancetype)init {
...
self.noteList = [AEManagedValue new];
...
}
-(int)addNoteWithFrequency:(float)frequency
velocity:(float)velocityatTime:(uint64_t)startTime {
// Get old list, and copy it to new one
struct NoteList * oldNoteList =self.noteList.pointerValue
struct NoteList * newNoteList =
malloc([self sizeOfNoteListWithCount:oldNoteList->count 1]);
memcpy(newNoteList,
oldNoteList, [selfsizeOfNoteListWithCount:oldNoteList->count]);
// Update
newNoteList->count ;
newNoteList->notes[newNoteList->count-1]= ...;
// Assign new list - old value will beautomatically freed at a safe time
self.noteList.pointerValue = newNoteList;
}
voidMyAudioRenderFunction(__unsafe_unretained MyClass * self) {
// Get latest value
struct NoteList * noteList =
AEManagedValueGetValue(self->_noteList);
// Make noise
for ( int i=0; i<noteList->noteCount;i ) {
ProduceAudioForNote(¬eList->notes[i]);
}
}
或者,让事情变得更简单:AEArray,同样源自Amazing Audio Engine 2,其建立在AEManagedValue用以实现NSArray和C数组。你可以在音频线程之间安全地访问其间的映射,也可以直接在音频线程上访问Objective-C实例或者提供一个在这些Objective-C对象和C结构之间进行映射的块。
因此,我们可以再次回顾示例。假设MyNote在NSArray中有一个Objective-C类:
代码语言:javascript复制@interface MyClass ()
@property (nonatomic, strong)NSMutableArray * playingNotes;
@property (nonatomic, strong) AEArray *noteArray;
@end
@implementation MyClass
- (instancetype)init {
...
self.playingNotes = [NSMutableArray array];
self.noteArray = [[AEArray alloc]initWithCustomMapping:^void *(id item) {
// We'll provide a map between theObjective-C MyNote instance, the properties of
// which we cannot safely access on theaudio thread; and our C struct, which we
// *can* safely access.
// This happens on the main thread during acall to "updateWithContentsOfArray",
// and the pointer we return will be freedautomatically when the original
// Objective-C object is removed from thearray.
struct Note * note = malloc(sizeof(structNote));
note->frequency =((MyNote*)item).frequency;
note->velocity =((MyNote*)item).velocity;
note->startTime =((MyNote*)item).startTime;
return note;
}];
...
}
- (int)addNote:(MyNote *)note {
// Update our array
[self.playingNotes addObject:note];
[self.noteArrayupdateWithContentsOfArray:self.playingNotes];
}
voidMyAudioRenderFunction(__unsafe_unretained MyClass * self) {
// Enumerate the pointers in the array
AEArrayEnumeratePointers(self->_noteArray,struct Note *, note, {
ProduceAudioForNote(note);
}
}
来自 TheAmazing Audio Engine 2的 [AEMessageQueue](http://theamazingaudioengine.com/doc2/interface_a_e_message_queue.html) 可被开发者用于安排要音频线程上执行的块:
代码语言:javascript复制[self.messageQueueperformBlockOnAudioThread:^{
_state = newState;
}];
…从另一个角度看,开发者可以在主线程上安全地计划目标或者选择器:
代码语言:javascript复制AEMessageQueuePerformSelectorOnMainThread(
self->_messageQueue,
self,
@selector(doSomethingWithTrack:),
AEArgumentScalar(track),
AEArgumentNone);
这个工作有点像libdispatch,但是音频线程是完全安全的。
那么,如何知道自己的项目是否有问题?
我创建了一个可以使该诊断琐事变得更容易一些的工具,其思想来自泰勒·霍利迪(TaylorHolliday)(Audulus名望)。
这是一个名为RealtimeWatchdog的小型库(现在也已内置在The AmazingAudio Engine 2和版本1中)。在您将其添加到项目中后,它将密切监控音频线程上的任何不安全活动,并在发现任何异常时发出警告。
它不会捕获所有内容,也不会捕获Apple自己的系统代码中的任何内容,但是它将捕获一些在您的代码以及您正在使用的任何静态库的代码中的锁、内存分配、所有正在被使用的Objective-C活动(但不包括Swift)、所有对象保留以及一些通用I/O任务。
要使用它,只需将“RealtimeWatchdog”添加到您的Cocoapods Podfile中,(“pod ‘RealtimeWatchdog’”)然后运行podinstall即可。它会自动通知您有关调试版本的所有违规信息,并且绝对不会在您的发行的版本中执行任何操作。对于调试版本,它会减慢Objective-C消息的发送速度,因此您可以随时通过注释由AERealtimeWatchdog.h定义的REALTIME_WATCHDOG_ENABLED来禁用它。
如果您不使用Cocoapods,请查看GitHub存储库上的说明。
如果您使用的是AmazingAudio Engine 1或2,则只需取消注释其中的由AERealtimeWatchdog.h定义的REALTIME_WATCHDOG_ENABLED即可将其打开。
最后的想法
由于iOS平台的便携性、便利性、可负担性和强大功能,越来越多的音乐家正在出售其所使用的硬件并转向iOS。我经常听到用户关于Loopy和Audiobus如何帮助他们开创无限可能,不能不说这令人兴奋无比。
但是我也经常听到失望和沮丧:应用程序故障、无法正常工作、需要解决方案……许多故障都是因为开发者违反了一些本可以避免的注意事项,这些都是可以被优化的。
需要注意的是,该建议仅基于对系统级情况的假设。但是iOS和Mac OS X是封闭式系统,我们只能通过opensource.apple.com来了解一下,选择的范围非常之小。即使我们遵循这些规则,问题依旧可能会发生。因此,我们所能做的是进行有根据的猜测,并在理想情况下进行测试和实验,尽管这可能很困难但在技术上可行。
您可以加入Core Audio API邮件列表并提出问题,打开Xcode并查看您在音频代码中正在做什么;尝试使用RealtimeWatchdog来检查您的代码以及所使用的任何第三方库的代码,也可以考虑选择C ——对于音频而言,C 往往比Objective-C更安全,并且比单独使用C提供更多的功能。