WebRTC 中的临界锁实现:从 CritScope 到 RAII 机制的深度解析
本文所有源码均基于 WebRTC M85 (branch-heads/4183) 版本进行分析。
一、引言:一行"什么都没做"的代码
在阅读 WebRTC 源码时,你可能经常会看到类似这样的代码:
cpp
void RtpVideoSender::SetFecAllowed(bool fec_allowed) {
rtc::CritScope cs(&crit_); // crit_ 是 rtc::CriticalSection 类型
fec_allowed_ = fec_allowed;
}
如果你的主力语言不是 C++,第一次看到这段代码可能会感到困惑:
cs只是一个局部变量,创建之后似乎什么都没做;- 既没有调用
cs.lock(),也没有cs.unlock(); - 这样的代码到底有什么意义?
事实上,这正是 C++ 语言的精妙之处------RAII(Resource Acquisition Is Initialization,资源获取即初始化) 机制的典型应用。
本文将从 WebRTC 的 rtc::CritScope 实现出发,深入讲解这种"构造函数加锁、析构函数解锁"的临界区实现方式,并扩展到 RAII 机制的原理、优势及其在现代 C++ 中的广泛应用。
二、CritScope 的实现原理
2.1 源码分析
让我们先来看看 rtc::CritScope 的具体实现:
cpp
// rtc_base/critical_section.h / .cc (WebRTC M85)
class CritScope {
public:
explicit CritScope(const CriticalSection* cs) : cs_(cs) {
cs_->Enter(); // 加锁
}
~CritScope() {
cs_->Leave(); // 解锁
}
private:
const CriticalSection* const cs_;
// 禁止拷贝和赋值
CritScope(const CritScope&) = delete;
CritScope& operator=(const CritScope&) = delete;
};
CritScope 的设计非常简洁,只有两个关键函数:
- 构造函数 :接收一个
CriticalSection指针,并立即调用Enter()加锁。 - 析构函数 :调用
Leave()解锁。
2.2 CriticalSection 的跨平台实现
CriticalSection 在不同操作系统上有不同的底层实现:
POSIX 系统(Linux / macOS):
cpp
class CriticalSection {
public:
CriticalSection() {
pthread_mutex_init(&mutex_, nullptr);
}
~CriticalSection() {
pthread_mutex_destroy(&mutex_);
}
void Enter() const {
pthread_mutex_lock(&mutex_);
}
void Leave() const {
pthread_mutex_unlock(&mutex_);
}
private:
mutable pthread_mutex_t mutex_;
};
Windows 系统:
cpp
class CriticalSection {
public:
CriticalSection() {
InitializeCriticalSection(&crit_);
}
~CriticalSection() {
DeleteCriticalSection(&crit_);
}
void Enter() const {
EnterCriticalSection(&crit_);
}
void Leave() const {
LeaveCriticalSection(&crit_);
}
private:
mutable CRITICAL_SECTION crit_;
};
2.3 为什么这样设计能实现加锁保护?
回到最初的代码:
cpp
void RtpVideoSender::SetFecAllowed(bool fec_allowed) {
rtc::CritScope cs(&crit_);
fec_allowed_ = fec_allowed;
}
执行流程如下:
- 进入函数 :创建局部变量
cs,调用CritScope构造函数 →crit_.Enter()→ 加锁。 - 执行业务逻辑 :
fec_allowed_ = fec_allowed;(此时持有锁,线程安全)。 - 离开函数 :
cs离开作用域,自动调用析构函数 →crit_.Leave()→ 解锁。
关键点在于:C++ 保证局部对象在离开作用域时一定会调用析构函数 ,无论是正常返回、提前 return,还是抛出异常。
这就是为什么"看起来什么都没做"的一行代码,实际上完成了完整的加锁-解锁流程。
三、RAII 机制详解
3.1 什么是 RAII?
RAII 是 C++ 之父 Bjarne Stroustrup 提出的一种编程惯用法,全称是 Resource Acquisition Is Initialization(资源获取即初始化)。
核心思想是:
- 资源的获取 (如分配内存、打开文件、获取锁)发生在对象构造时;
- 资源的释放 (如释放内存、关闭文件、释放锁)发生在对象析构时。
由于 C++ 保证对象离开作用域时析构函数一定会被调用,因此资源的释放是自动且确定的。
3.2 RAII 的核心优势
1. 异常安全
考虑以下手动管理锁的代码:
cpp
void foo() {
mutex_.lock();
doSomething(); // 如果这里抛出异常...
mutex_.unlock(); // 这行永远不会执行,锁永远不会释放!
}
如果 doSomething() 抛出异常,unlock() 永远不会被调用,导致死锁。
而使用 RAII:
cpp
void foo() {
std::lock_guard<std::mutex> lock(mutex_);
doSomething(); // 即使这里抛出异常...
// lock 离开作用域时,析构函数自动调用 unlock()
}
无论函数如何退出(正常返回、异常、提前 return),锁都会被正确释放。
2. 代码简洁
不需要在每个 return 语句前手动释放资源:
cpp
// 手动管理(容易出错)
int foo() {
mutex_.lock();
if (condition1) {
mutex_.unlock(); // 别忘了!
return -1;
}
if (condition2) {
mutex_.unlock(); // 别忘了!
return -2;
}
// ... 更多分支 ...
mutex_.unlock();
return 0;
}
// RAII(简洁安全)
int foo() {
std::lock_guard<std::mutex> lock(mutex_);
if (condition1) return -1; // 自动解锁
if (condition2) return -2; // 自动解锁
return 0; // 自动解锁
}
3. 防止遗漏
编译器保证析构函数一定会被调用,程序员不可能"忘记"释放资源。
3.3 与其他语言的对比
Java:try-finally
java
class X {
private final ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock();
try {
// method body...
} finally {
lock.unlock(); // 必须手动写
}
}
}
Java 需要显式的 try-finally 块来保证锁的释放。虽然 Java 7 引入了 try-with-resources,但它只适用于实现了 AutoCloseable 接口的资源,且语法上仍然比 C++ RAII 更冗长。
Go:defer
go
func foo() {
mu.Lock()
defer mu.Unlock() // 延迟执行
// function body...
}
Go 的 defer 机制也能实现类似效果,但:
defer是运行时机制,有一定性能开销;- 需要程序员手动编写
defer语句,仍有遗漏风险; defer的执行顺序是 LIFO(后进先出),在复杂场景下可能造成困惑。
正如原文作者所说:"在笔者看来,RAII 是比 Golang 的 defer 机制更加简洁的存在。"
Rust:Drop trait
rust
struct LockGuard<'a> {
mutex: &'a Mutex,
}
impl<'a> Drop for LockGuard<'a> {
fn drop(&mut self) {
self.mutex.unlock();
}
}
Rust 通过 Drop trait 实现了类似 RAII 的机制,且在编译期通过所有权系统保证资源安全。这是 Rust 内存安全的重要基石之一。
四、现代 C++ 中的 RAII 实践
C++ 标准库提供了丰富的 RAII 封装,在实际开发中应优先使用这些标准设施。
4.1 锁管理
| 类型 | 引入版本 | 特点 |
|---|---|---|
std::lock_guard<std::mutex> |
C++11 | 最简单的 RAII 锁,不可中途解锁 |
std::unique_lock<std::mutex> |
C++11 | 更灵活,支持延迟加锁、中途解锁、条件变量 |
std::scoped_lock |
C++17 | 支持同时锁定多个互斥量,避免死锁 |
示例:
cpp
#include <mutex>
std::mutex mtx1, mtx2;
void foo() {
// C++11: lock_guard
std::lock_guard<std::mutex> lock(mtx1);
// ...
}
void bar() {
// C++17: scoped_lock,同时锁定多个互斥量
std::scoped_lock lock(mtx1, mtx2);
// ...
}
4.2 智能指针
| 类型 | 特点 |
|---|---|
std::unique_ptr<T> |
独占所有权,不可拷贝,可移动 |
std::shared_ptr<T> |
共享所有权,引用计数 |
std::weak_ptr<T> |
弱引用,不增加引用计数,用于打破循环引用 |
示例:
cpp
#include <memory>
void foo() {
auto ptr = std::make_unique<MyClass>();
ptr->doSomething();
// 离开作用域时,ptr 自动 delete 内部对象
}
4.3 文件流
cpp
#include <fstream>
void foo() {
std::ofstream file("output.txt");
file << "Hello, RAII!";
// 离开作用域时,file 自动关闭
}
4.4 建议
在现代 C++ 开发中:
- 优先使用标准库提供的 RAII 封装 ,而非手动
new/delete、lock/unlock; - 如果需要自定义 RAII 类,遵循 Rule of Five(正确实现析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值);
- 考虑禁用拷贝(如
CritScope所做的),避免资源被意外共享。
五、WebRTC 的演进:从 CritScope 到 webrtc::Mutex
5.1 为什么废弃 CritScope?
从 WebRTC M86 (branch-heads/4240) 版本开始,rtc::CritScope 和 rtc::CriticalSection 被废弃,改为使用新的 webrtc::Mutex 和 webrtc::MutexLock。
主要原因是:CriticalSection 是递归锁(可重入锁),而递归锁存在一些难以解决的问题。
5.2 递归锁 vs 非递归锁
递归锁(Recursive Lock):
- 允许同一线程多次获取同一把锁;
- 每次
lock()必须对应一次unlock(); - 内部维护一个计数器。
非递归锁(Non-recursive Lock):
- 同一线程重复获取同一把锁会导致死锁;
- 实现更简单,性能更好。
5.3 递归锁的问题
-
掩盖设计缺陷:如果代码意外地重入了加锁区域,递归锁会"默默工作",而非递归锁会立即死锁,暴露问题。
-
难以推理:当你看到一段持有锁的代码时,很难判断"这把锁是否已经被当前线程持有"。
-
性能开销:递归锁需要维护持有者线程 ID 和计数器,比非递归锁略慢。
-
违反最小权限原则:大多数情况下,代码并不需要递归加锁的能力。
5.4 新的 webrtc::Mutex
cpp
// api/units/mutex.h (WebRTC M86+)
class Mutex {
public:
Mutex() = default;
Mutex(const Mutex&) = delete;
Mutex& operator=(const Mutex&) = delete;
void Lock();
void Unlock();
// ...
};
class MutexLock {
public:
explicit MutexLock(Mutex* mutex) : mutex_(mutex) {
mutex_->Lock();
}
~MutexLock() {
mutex_->Unlock();
}
// ...
};
新的 webrtc::Mutex 是非递归锁,更符合现代并发编程的最佳实践。
参考:WebRTC Issue 11567: Refactor webrtc to use a non-recursive CriticalSection
六、实践建议与总结
6.1 RAII 在并发编程中的价值
- 自动化资源管理:减少人为错误,避免忘记释放锁。
- 异常安全:即使发生异常,锁也能正确释放。
- 代码简洁 :无需在每个
return前手动解锁。 - 可维护性:资源的获取和释放逻辑集中在一处。
6.2 给读者的建议
-
优先使用标准库 :在自己的 C++ 项目中,使用
std::lock_guard/std::scoped_lock,而非手动lock/unlock。 -
遵循 RAII 原则:如果需要自定义资源管理类,遵循"构造获取、析构释放"的原则。
-
避免递归锁:除非有明确的设计需求,否则使用非递归锁。递归锁往往是设计问题的信号。
-
禁用拷贝:RAII 类通常应该禁用拷贝构造和拷贝赋值,避免资源被意外共享。
6.3 结语
回到文章开头的那段代码:
cpp
void RtpVideoSender::SetFecAllowed(bool fec_allowed) {
rtc::CritScope cs(&crit_);
fec_allowed_ = fec_allowed;
}
现在你应该完全理解了:看似"什么都没做"的一行代码,背后是 C++ 语言设计的精妙之处。
RAII 不仅仅是一种编程技巧,更是 C++ 资源管理的核心哲学。理解并善用 RAII,是写出安全、简洁、可维护 C++ 代码的关键。