各位同仁,各位对高性能并发编程充满热情的开发者们,欢迎来到今天的讲座。我们将深入探讨现代C++互斥锁设计中的一个核心概念——“自适应互斥锁”(Adaptive Mutex),以及它为何在进入内核挂起前会进行短暂的自旋。这并非一个简单的技术细节,而是多核时代操作系统与编程语言运行时协同优化的一个精妙体现。
并发控制的基石:互斥锁的必要性
在多线程编程中,我们经常会遇到多个线程同时访问和修改共享资源的情况。如果不对这种访问进行协调,就可能导致数据竞争(data race),从而产生不可预测的行为,如数据损坏、程序崩溃等。为了避免这种情况,我们需要引入同步机制,其中最基础、最常用的一种就是互斥锁(Mutex)。
互斥锁的核心思想是确保在任何给定时刻,只有一个线程能够持有锁并访问受保护的共享资源。当一个线程成功获取锁后,它就可以安全地进入临界区(critical section)操作数据。其他试图获取同一个锁的线程将被阻塞,直到持有锁的线程释放它。
然而,互斥锁的实现并非没有代价。其性能开销是高性能并发应用中一个需要重点关注的问题。我们今天的主题,正是围绕如何优化这个开销而展开。
传统互斥锁:纯内核模式的代价
在早期或一些简单的互斥锁实现中,当一个线程尝试获取已被占用的互斥锁时,它会立即通过系统调用(system call)进入操作系统内核。内核会将该线程标记为“阻塞”状态,并将其从CPU调度队列中移除,转而调度其他可运行的线程。当持有锁的线程释放锁时,它会再次通过系统调用通知内核,内核随后会将等待该锁的一个或多个线程唤醒,使其重新进入可运行状态。
这种纯内核模式的互斥锁(例如,在Linux上,pthread_mutex在没有优化时可能更多地依赖这种模式;在Windows上,CreateMutex或未优化的CRITICAL_SECTION可能也有类似行为)在逻辑上是正确的,但在性能上却存在显著的开销:
- 上下文切换(Context Switch):从用户态切换到内核态,再从内核态切换回用户态,这是一个代价高昂的操作。它涉及到保存当前线程的CPU寄存器状态、程序计数器、栈指针等,加载内核的寄存器状态,执行内核代码,然后再反向操作以恢复用户线程或切换到另一个用户线程。每次上下文切换都可能消耗数百到数千个CPU周期。
- TLB(Translation Lookaside Buffer)失效和缓存污染:上下文切换通常会导致CPU的TLB(一个用于加速虚拟地址到物理地址翻译的缓存)和各级数据缓存(L1, L2, L3 cache)中的内容失效。新调度的线程需要重新加载其工作集到缓存中,这会引入额外的内存访问延迟。
- 调度开销:内核需要维护等待队列、唤醒机制,并重新评估哪些线程应该被调度执行。这些操作本身也需要消耗CPU时间。
- 优先级反转(Priority Inversion):虽然不是纯内核模式特有,但在没有自旋的纯内核模式下,如果一个高优先级线程因等待一个被低优先级线程持有的锁而阻塞,那么高优先级线程就会被挂起,直到低优先级线程被调度并释放锁。这可能导致系统响应时间变差。
考虑一个临界区非常短的场景,例如仅仅是一个简单的计数器递增操作。如果每次访问都需要进行一次系统调用,那么互斥锁本身的开销可能远远超过临界区操作本身的开销,严重影响程序的吞吐量。
下面是一个简化的、概念上的纯内核模式互斥锁的流程:
// 假设的纯内核模式互斥锁实现概念
class KernelMutex {
private:
std::atomic<bool> locked_flag; // 锁状态
// 隐藏的内核对象句柄,例如 Linux 的 futex 地址或 Windows 的 HANDLE
public:
KernelMutex() : locked_flag(false) {}
void lock() {
while (true) {
// 尝试原子地将锁状态从 false 设置为 true
if (!locked_flag.exchange(true, std::memory_order_acquire)) {
// 成功获取锁
return;
}
// 如果未能获取锁,则调用内核函数将当前线程挂起
// 这是一个概念性函数,实际会是 futex(FUTEX_WAIT, ...) 或 WaitForSingleObject
kernel_wait_on_mutex_object();
}
}
void unlock() {
// 释放锁
locked_flag.store(false, std::memory_order_release);
// 通知内核唤醒等待该锁的线程
// 这是一个概念性函数,实际会是 futex(FUTEX_WAKE, ...) 或 ReleaseMutex
kernel_signal_mutex_object();
}
};
// 实际的 std::mutex 远比这复杂且优化
// 这是一个演示概念的例子
void increment_counter_kernel_mode(long& counter, KernelMutex& m) {
m.lock();
counter++;
m.unlock();
}
表1:纯内核模式互斥锁的优缺点
| 特性 | 优点 | 缺点 |
|---|---|---|
| 资源利用 | 不占用CPU,线程被挂起,节省CPU周期 | 每次争用都涉及系统调用,开销大 |
| 性能 | 临界区长、争用高时表现尚可 | 临界区短、争用低时性能极差 |
| 实现复杂 | 内核负责调度,用户空间实现相对简单 | 依赖操作系统API,跨平台实现需适配 |
| 适用场景 | 传统服务器应用,对延迟不敏感,高争用场景 | 高性能低延迟系统,临界区短的场景 |
另一种极端:自旋锁(Spinlock)
与纯内核模式互斥锁相对的,是自旋锁(Spinlock)。自旋锁是一种用户模式的同步机制,它在获取锁失败时,不会让线程进入睡眠状态,而是让线程在一个紧密的循环中反复检查锁的状态,直到锁被释放。这个“忙等待”的过程就是“自旋”。
自旋锁的实现非常简单,通常只需要一个原子变量:
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>
// 简单的自旋锁实现
class Spinlock {
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT; // 使用 atomic_flag 作为锁状态
public:
void lock() {
// test_and_set 会原子地设置 flag 为 true 并返回其旧值
// 如果旧值为 true (表示锁已被持有),则继续循环
while (flag.test_and_set(std::memory_order_acquire)) {
// 可选:在这里插入 CPU 暂停指令,例如 _mm_pause()
// std::this_thread::yield(); // 提示调度器当前线程可以被让出CPU
}
}
void unlock() {
flag.clear(std::memory_order_release); // 原子地将 flag 设置为 false
}
};
long global_counter = 0;
Spinlock my_spinlock;
void worker_spinlock() {
for (int i = 0; i < 1000000; ++i) {
my_spinlock.lock();
global_counter++;
my_spinlock.unlock();
}
}
/*
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(worker_spinlock);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter (Spinlock): " << global_counter << std::endl;
return 0;
}
*/
自旋锁的优点显而易见:
- 无系统调用开销:完全在用户态执行,避免了上下文切换、TLB失效和调度开销。
- 低延迟:如果锁只被持有很短的时间,自旋锁可以非常快速地获取锁,因为线程不需要被挂起再唤醒。
然而,自旋锁的缺点也同样突出,甚至更为严重:
- CPU浪费:当锁被长时间持有时,自旋线程会持续占用CPU核心,进行无谓的忙等待,浪费大量的CPU周期。这不仅浪费了电能,还可能导致其他有用的工作无法及时执行。
- 缓存争用(Cache Contention):多个CPU核心在自旋时,会反复读取同一个内存位置(锁变量)。这会导致缓存行在不同核心之间频繁地来回传输(cache line bouncing),从而增加内存总线流量和缓存同步开销,进一步降低系统性能。
- 优先级反转加剧:如果一个高优先级线程自旋等待一个被低优先级线程持有的锁,并且低优先级线程被操作系统抢占(preempt),那么高优先级线程会一直忙等待,直到低优先级线程重新被调度并释放锁。这比纯内核模式更糟糕,因为高优先级线程在自旋期间是占用CPU的。
- 不适用于单核系统:在单核系统上,如果持有锁的线程被抢占,那么自旋等待的线程将永远无法获取锁,因为它无法运行来释放锁,从而导致死锁。因此,自旋锁通常只适用于多核处理器。
- 不公平性:自旋锁通常不保证公平性(fairness),即等待时间最长的线程不一定最先获取锁。
表2:自旋锁的优缺点
| 特性 | 优点 | 缺点 |
|---|---|---|
| 资源利用 | 占用CPU,忙等待,浪费大量CPU周期 | 无系统调用,低延迟,适合临界区极短的情况 |
| 性能 | 临界区极短、争用低时性能极优 | 临界区长、争用高时性能极差 |
| 实现复杂 | 实现简单,仅需原子操作 | 不适用于单核,可能加剧优先级反转 |
| 适用场景 | 内核态编程,临界区极短且可预测的场景 | 用户态通用编程,临界区长度不确定的场景 |
困境:内核开销与CPU浪费的权衡
我们看到了两种极端情况:纯内核模式互斥锁在争用时开销巨大,但能节省CPU;自旋锁在争用时浪费CPU,但能降低延迟。那么,有没有一种方法能够结合两者的优点,规避两者的缺点呢?
答案是肯定的,这就是“自适应互斥锁”(Adaptive Mutex)的核心思想。
表3:纯内核模式与自旋锁的对比
| 特性 | 纯内核模式互斥锁 | 自旋锁 |
|---|---|---|
| 核心机制 | 系统调用,线程挂起/唤醒 | 用户态忙等待(自旋) |
| 争用开销 | 高(上下文切换,调度) | 中高(CPU浪费,缓存争用) |
| 延迟 | 高(需要内核参与) | 低(如果锁释放快) |
| CPU利用率 | 高(线程挂起不占用CPU) | 低(忙等待占用CPU) |
| 适用场景 | 临界区较长,或争用频繁且不可预测的场景 | 临界区极短,且持有锁时间可预测的特定场景 |
自适应互斥锁:两全其美的策略
现代C++标准库中的std::mutex以及许多高性能并发库(如TBB、Boost)中的互斥锁,都采用了自适应(或称为混合型)策略。其基本思想是:在尝试获取锁失败时,首先进行短暂的自旋,如果自旋一段时间后仍未能获取锁,则退回到内核模式,将线程挂起。
为什么先自旋?
这个策略基于一个关键的假设和观察:在多核处理器上,如果一个线程尝试获取一个已被占用的锁,那么持有锁的线程很可能正在另一个CPU核心上运行,并且很快就会完成其临界区操作并释放锁。如果锁很快就会被释放,那么与其进行代价高昂的系统调用和上下文切换,不如让等待线程短暂自旋,等待锁的释放。
想象一下,你正在排队等待使用一台打印机。如果你知道前面的人只需要打印一页纸,并且很快就会完成,你会选择站在那里等几秒钟(自旋),还是立刻去办公室的另一个角落,找个地方坐下休息,然后等前台通知你打印机空闲了再回来(内核挂起)?显然,对于短暂的等待,直接等待的效率更高。
具体来说,自旋的优点在于:
- 避免系统调用开销:如果锁在自旋周期内被释放,那么就完全避免了用户态到内核态的切换,以及随之而来的调度、TLB和缓存开销。
- 降低延迟:对于短临界区,自旋可以显著降低获取锁的延迟。
- 利用多核优势:自旋在多核系统上更有效,因为持有锁的线程可以在不同的核心上并发执行。
自旋多久?何时退回内核?
这是自适应互斥锁设计的核心挑战和艺术。自旋时间过短,你仍然会频繁地陷入内核;自旋时间过长,你又会浪费CPU周期,重蹈自旋锁的覆辙。这个“自旋阈值”通常是动态调整的,并受到多种因素的影响:
- CPU核心数量:核心越多,自旋的有效性越高。
- 系统负载:系统空闲时,可以适当增加自旋时间;系统繁忙时,应减少自旋时间,尽快让出CPU。
- 历史争用情况:如果一个锁在过去经常被长时间持有,那么可能倾向于减少自旋时间。
- 临界区长度预测:虽然难以精确预测,但某些启发式算法可以尝试评估。
- CPU指令集支持:现代CPU提供了专门的“暂停”指令。
_mm_pause指令的妙用
在x86架构上,Intel引入了PAUSE指令(由C++中的_mm_pause()内联函数提供)。这个指令在自旋循环中扮演着关键角色:
- 降低功耗:
PAUSE指令会给处理器一个提示,表明当前线程处于自旋等待状态。处理器可以利用这个提示,进入一个低功耗状态,而不是全力执行空循环。 - 改善超线程性能:在支持超线程(Hyper-Threading)的处理器上,
PAUSE指令可以避免在自旋循环中过度消耗执行单元,从而让另一个逻辑处理器更好地利用共享资源。 - 防止内存乱序:虽然现代内存模型和编译器优化通常能处理好这个问题,但在某些紧密的自旋循环中,
PAUSE指令有时也能起到间接的内存顺序保障作用(尽管这不是其主要目的)。
一个带有_mm_pause的自旋锁示例(仍是概念性的,并非完整的自适应互斥锁):
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>
#ifdef _MSC_VER
#include <intrin.h> // For _mm_pause on MSVC
#define PAUSE_INSTRUCTION _mm_pause()
#elif defined(__GNUC__) || defined(__clang__)
#include <x86intrin.h> // For _mm_pause on GCC/Clang
#define PAUSE_INSTRUCTION _mm_pause()
#else
#define PAUSE_INSTRUCTION do { } while(0) // Fallback for other compilers/architectures
#endif
class SpinlockWithPause {
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
// 插入 PAUSE 指令,优化自旋等待
PAUSE_INSTRUCTION;
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
/*
long global_counter_pause = 0;
SpinlockWithPause my_spinlock_pause;
void worker_spinlock_pause() {
for (int i = 0; i < 1000000; ++i) {
my_spinlock_pause.lock();
global_counter_pause++;
my_spinlock_pause.unlock();
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(worker_spinlock_pause);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter (Spinlock with Pause): " << global_counter_pause << std::endl;
return 0;
}
*/
从自旋到内核:futex的魔力(Linux为例)
在Linux上,std::mutex(通常通过pthread_mutex_t实现)的自适应行为很大程度上依赖于futex(Fast Userspace muTEX)系统调用。futex是一种在用户空间和内核空间之间进行高效同步的机制,它完美支持了自适应互斥锁的需求。
futex的工作原理概括如下:
- 用户态尝试:线程首先在用户空间通过原子操作尝试获取锁。如果成功,则无需内核介入。
- 自旋阶段:如果锁已被占用,线程会进入一个短暂的自旋循环,尝试再次获取锁。这个自旋阶段可以包含
PAUSE指令。 - 内核介入:如果自旋达到一定阈值后仍未能获取锁,线程会调用
futex(FUTEX_WAIT, ...)。这个系统调用会检查锁的状态:- 如果锁在此刻已经被释放(在自旋期间或
FUTEX_WAIT检查前的一瞬间),futex_wait会立即返回,线程继续执行,避免了实际的挂起。 - 如果锁仍然被占用,内核会将当前线程放入一个等待队列,并将其挂起。
- 如果锁在此刻已经被释放(在自旋期间或
- 唤醒:当持有锁的线程释放锁时,它会通过原子操作更新锁的状态,并可能调用
futex(FUTEX_WAKE, ...)。这个系统调用会通知内核唤醒等待该futex地址的一个或多个线程。
通过futex,内核只在真正需要挂起或唤醒线程时才介入,从而大大减少了系统调用的频率。这正是自适应互斥锁在Linux上高效实现的关键。
在Windows上,CRITICAL_SECTION对象也具有自适应性。它内部也维护了一个自旋计数(Spin Count),在尝试进入临界区失败时,会先进行指定次数的自旋。如果自旋后仍无法进入,才会调用内核API(如WaitForSingleObject)将线程挂起。SRWLock(Slim Reader/Writer Lock)是Windows Vista及更高版本中引入的更轻量级的读写锁,它也提供了类似的自适应行为。
表4:自适应互斥锁的优缺点
| 特性 | 优点 | 缺点 |
|---|---|---|
| 资源利用 | 低争用时节省CPU(无系统调用),高争用时节省CPU(挂起) | 仍然有短暂的CPU忙等待(自旋阶段) |
| 性能 | 综合性能最佳,兼顾低延迟和高吞吐量 | 引入了自旋阈值的管理复杂性 |
| 实现复杂 | 系统级别复杂,用户使用简单 | 内部实现复杂,需要操作系统和运行时支持 |
| 适用场景 | 绝大多数通用并发场景,平衡了性能和资源利用 | 对延迟要求极高且临界区极短的特定场景可能不如纯自旋锁,但通常足够好 |
概念性自适应互斥锁的实现演示
为了更好地理解自适应互斥锁的工作原理,我们来构建一个高度简化的概念性实现。请注意,这只是一个教学示例,实际的std::mutex要复杂得多,并且会依赖操作系统提供的底层原语(如futex或CRITICAL_SECTION)来实现线程的挂起和唤醒。
#include <atomic>
#include <thread>
#include <iostream>
#include <chrono>
#include <vector>
#include <condition_variable> // 用于模拟内核级别的等待/唤醒
#include <mutex> // 用于 condition_variable
#ifdef _MSC_VER
#include <intrin.h>
#define PAUSE_INSTRUCTION _mm_pause()
#elif defined(__GNUC__) || defined(__clang__)
#include <x86intrin.h>
#define PAUSE_INSTRUCTION _mm_pause()
#else
#define PAUSE_INSTRUCTION do { } while(0)
#endif
// 一个高度简化的自适应互斥锁示例
class SimpleAdaptiveMutex {
private:
std::atomic<bool> locked_flag; // 锁状态
// 用于模拟内核级别的线程挂起/唤醒
std::condition_variable cv;
std::mutex cv_mutex; // condition_variable 需要一个 mutex 来保护其内部状态
// 自旋尝试的次数阈值
// 实际的 std::mutex 会根据系统负载、CPU核数等动态调整
static constexpr int SPIN_THRESHOLD = 1000;
public:
SimpleAdaptiveMutex() : locked_flag(false) {}
void lock() {
int spin_count = 0;
while (true) {
// 尝试原子地获取锁
if (!locked_flag.exchange(true, std::memory_order_acquire)) {
// 成功获取锁
return;
}
// 如果锁已被占用,进行自旋
if (spin_count < SPIN_THRESHOLD) {
spin_count++;
PAUSE_INSTRUCTION; // 优化自旋等待
// 在非常多线程争用时,也可以考虑 std::this_thread::yield()
// std::this_thread::yield();
} else {
// 自旋达到阈值,退回到内核模式,等待唤醒
std::unique_lock<std::mutex> lock(cv_mutex);
// 再次检查锁状态,防止在自旋阈值达到后,但在进入等待前锁被释放
if (!locked_flag.load(std::memory_order_acquire)) {
// 如果锁已经释放,则不用等待,直接循环回去重新尝试获取
continue;
}
cv.wait(lock, [this]{ return !locked_flag.load(std::memory_order_acquire); });
// 被唤醒或条件满足后,循环重新尝试获取锁
}
}
}
void unlock() {
// 释放锁
locked_flag.store(false, std::memory_order_release);
// 通知一个等待线程。实际的 futex_wake 可以唤醒多个或所有。
cv.notify_one();
}
};
long global_counter_adaptive = 0;
SimpleAdaptiveMutex my_adaptive_mutex;
void worker_adaptive() {
for (int i = 0; i < 1000000; ++i) {
my_adaptive_mutex.lock();
global_counter_adaptive++;
my_adaptive_mutex.unlock();
}
}
/*
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(worker_adaptive);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final counter (Adaptive Mutex): " << global_counter_adaptive << std::endl;
return 0;
}
*/
在这个示例中,SimpleAdaptiveMutex首先尝试通过原子操作获取锁。如果失败,它会进入一个循环,最多自旋SPIN_THRESHOLD次。每次自旋都会调用PAUSE_INSTRUCTION以优化CPU使用。如果自旋结束后锁仍然被占用,它就会使用std::condition_variable来模拟内核的挂起和唤醒机制。cv.wait会导致当前线程阻塞,直到unlock操作通过cv.notify_one唤醒它。
这种设计有效地平衡了忙等待的低延迟和挂起等待的低CPU消耗。
高级考量与优化技术
自适应互斥锁的内部实现远比我们刚才的简化示例复杂,并且在不断演进。以下是一些高级考量和优化技术:
- 指数退避(Exponential Backoff):在自旋阶段,不是每次都执行固定数量的
PAUSE指令,而是随着自旋失败次数的增加,逐渐增加每次自旋的延迟(例如,每次失败后延迟时间翻倍),以减少缓存争用和总线流量。 - 动态自旋阈值:真正的自适应互斥锁会根据系统实时情况(如CPU利用率、线程数量、锁的历史争用模式)动态调整自旋阈值。例如,如果系统CPU负载很高,或者锁经常被长时间持有,则会降低自旋阈值,更快地进入内核挂起。
- 缓存行对齐(Cache Line Alignment):为了避免伪共享(false sharing),互斥锁的内部状态变量通常会被放置在独立的缓存行中,确保不同核心的写操作不会不必要地使其他核心的缓存行失效。
- 公平性(Fairness):某些互斥锁实现会尝试提供某种程度的公平性,例如通过维护一个等待队列,确保等待时间最长的线程能够最先获取锁。但这通常会增加开销。
- 信号量或事件(Semaphores/Events):在底层实现中,自适应互斥锁最终会依赖操作系统提供的信号量或事件机制来挂起和唤醒线程。
futex可以看作是Linux上一种高效的信号量/事件机制。 - 硬件事务内存(Hardware Transactional Memory, HTM):这是一个更前沿的CPU特性(如Intel TSX),它允许处理器以事务的方式执行临界区代码。如果事务成功,所有修改都将原子地提交;如果发生冲突,事务将回滚,并可能回退到传统的锁机制。HTM旨在进一步减少锁的开销,尤其是在低冲突场景下。
性能考量与基准测试
自适应互斥锁的性能优势在以下场景中尤为突出:
- 低争用、短临界区:这是自适应互斥锁的“甜点”。在这种情况下,自旋阶段通常足以获取锁,完全避免了内核开销,提供了接近纯自旋锁的低延迟。
- 多核处理器:在多核系统上,持有锁的线程很可能在另一个核心上运行,自旋等待是有效的。
- 高争用但临界区很短:即使争用高,但如果临界区确实非常短,自适应互斥锁仍然可以有效地在短时间内获取锁,或者在达到自旋阈值后迅速退化到内核模式,避免了纯自旋锁的CPU浪费。
然而,在以下场景中,其优势可能不那么明显:
- 单核处理器:在单核系统上,自旋几乎总是一种浪费,因为如果持有锁的线程被抢占,自旋线程将永远无法获取锁。现代
std::mutex在单核系统上通常会优化掉自旋阶段。 - 极高争用且临界区较长:在这种情况下,自旋阶段很快就会达到阈值,线程会频繁地进入内核挂起,其行为接近纯内核模式互斥锁。自旋阶段带来的额外开销可能变得不那么重要,但也不会造成大的性能损失。
进行基准测试时,应在不同的线程数量、临界区长度和争用模式下测试互斥锁的性能,以全面了解其行为。测量指标应包括:
- 吞吐量(Throughput):单位时间内完成的操作数量。
- 延迟(Latency):单个操作完成所需的时间。
- CPU利用率(CPU Utilization):系统资源的消耗情况。
持续演进的并发原语
现代C++互斥锁采用的自适应策略,是并发编程领域不断追求性能优化和资源效率的必然结果。它巧妙地结合了自旋锁的低延迟和内核模式互斥锁的资源节约特性,为多核处理器上的通用并发编程提供了一个强大而高效的基础。
自适应互斥锁的设计与实现,是操作系统、编译器和硬件架构深度协同的产物。它们不断进化,以适应更复杂的处理器拓扑、更高级的指令集以及不断变化的系统负载模式。理解其内部机制,不仅能帮助我们更好地使用这些并发原语,更能启发我们设计出更高性能、更健壮的并发应用。在多核时代,如何高效地协调线程对共享资源的访问,仍然是高性能计算领域的核心挑战之一,而自适应互斥锁正是解决这一挑战的典范。