内核同步 - 第1部分
1. 技术要求
在开始之前,需要完成以下准备工作:
- 完成在线章节和内核工作区的设置。
- 准备一个运行Ubuntu 22.04 LTS(或更高版本的稳定发行版,或最近的Fedora发行版)的虚拟机,并安装所有必需的软件包。
- 强烈建议先设置工作区环境,包括克隆代码的GitHub仓库,仓库地址为:https://github.com/PacktPublishing/Linux-Kernel-Programming_2E 。
- 如果对基本的Linux驱动概念不熟悉,可以参考相关资料进行学习。
2. 关键概念
2.1 临界区、独占执行和原子性
在多核系统中编写软件时,并行运行多个代码路径通常是安全且可取的。但当多个并发代码路径访问共享可写数据(共享状态)时,必须保证在任何时刻只有一个线程可以对该数据进行操作,否则可能会导致数据损坏(数据竞争)。
临界区是指满足以下两个条件的代码路径:
- 条件一:代码路径可能是并发的,即存在并行运行的可能性。
- 条件二:该代码路径对共享可写数据进行读取和/或写入操作。
临界区的代码必须独占执行,有时还需要原子执行:
- 独占执行意味着在任何时刻,只有一个线程可以运行临界区的代码。
- 原子执行意味着操作不可分割,能够不间断地完成。
如果两个或多个线程可以同时执行临界区的代码,这就是一个错误,通常称为竞争条件或数据竞争。正确识别和保护临界区是软件设计和开发中必须确保的。
下面通过几个例子来帮助理解临界区的概念:
void qux(int factor)
{
int nok;
[ … ]
//------------------------ t1
nok += 10*PI;
//------------------------ t2
printf("..."); [ … ]
}
练习1
:在上述用户模式Pthreads(伪)代码片段中,假设函数代码可以由多个线程并行运行,那么时间t1和t2之间的代码区域是否构成临界区?
解决方案1
:判断一个代码区域是否为临界区,需要看它是否满足两个条件:可能并行执行且操作共享可写数据。在这个例子中,代码可以并行运行,但变量
nok
是局部变量,不是共享可写数据,每个线程都会在自己的栈上获得该变量的一个副本。因此,答案是否,该代码区域不是临界区,可以并行运行,无需显式保护。
再看另外两个内核模块(伪)代码片段:
static struct quux_drvctx {
...
} *mydrv;
write_quux() /* driver write method */
{
[ … ]
//-------------------- t1
mydrv->sensor2 = 1;
mydrv->hw = hw_zoom;
//-------------------- t2
[ … ]
}
static int glob;
static __init my_kmod_init(void) /* init method */
{
[ … ]
//-------------------- t1
glob += 14;
pr_info(...);
//-------------------- t2
[ … ]
}
[ … ]
module_init(my_kmod_init);
module_exit(my_kmod_cleanup);
练习2和3 :在上述内核模块(伪)代码片段中,假设代码可以由多个用户模式线程切换到内核空间并行运行,那么时间t1和t2之间的代码区域是否构成临界区?(解决方案可在后续查找,建议先自行尝试解决。)
2.2 原子性
原子操作是不可分割的操作。在现代处理器中,通常认为以下两种操作是原子的:
- 单条机器语言指令的执行。
- 对处理器字长(通常为32或64位)内的对齐基本数据类型的读写操作。例如,在64位系统上读写32位或64位整数是原子的,线程读取该变量时不会看到中间、撕裂或脏数据。但32位处理器对64位数据的操作不一定是原子的,可能会导致撕裂或脏读(或写)。
如果代码操作共享(全局或静态)可写数据,在没有显式同步机制的情况下,不能保证其独占执行。有时,临界区的代码需要原子执行,但并非总是如此,这取决于代码的运行上下文:
- 当临界区的代码在可能阻塞的安全睡眠进程上下文中运行时(如通过用户应用程序对驱动程序进行的典型文件操作,或内核线程或工作队列的执行路径),临界区不一定要真正原子执行,但需要独占执行。
- 当临界区的代码在非阻塞的原子上下文中运行时(如硬件中断:硬中断、小任务或软中断),则必须原子且独占执行。
下面通过一个概念示例来进一步说明:假设有三个线程从用户空间应用程序发出
open()
和
read()
系统调用,在多核系统上几乎同时对驱动程序进行操作。如果没有干预,它们可能会并行运行临界区的代码,从而对共享可写数据进行并行操作,导致数据竞争和损坏。
从数据访问的角度来看,不同时间段的操作情况如下:
| 时间段 | 数据访问情况 | 是否需要保护 | 能否并行运行 |
| ---- | ---- | ---- | ---- |
| t0 - t1 | 无或仅访问局部变量数据 | 否 | 是 |
| t1 - t2 | 访问全局/静态共享可写数据 | 是 | 否 |
| t2 - t3 | 无或仅访问局部变量数据 | 否 | 是 |
综上所述,临界区的代码必须满足以下要求:
- 始终独占执行。
- 在原子上下文中原子执行。
3. 经典示例:全局变量i++
考虑一个经典的例子:在可能并发的代码路径中对全局整数
i
进行自增操作。很多人可能会认为这个操作是原子的,但实际上现代硬件和软件(编译器和操作系统)会进行各种优化,使得情况变得复杂。
static int i = 5;
[ ... ]
foo()
{
[ ... ]
i ++; /* Is this safe? Yes, if this code path is truly exclusive or atomic...
* Alternately, if this code path's not exclusive, is the i ++ truly atomic?? */
}
这个自增操作是否安全呢?简短的答案是不安全,需要进行保护。因为这是一个临界区,在可能并发的代码路径中访问(读写)共享可写数据。更详细的答案取决于:
- 函数
foo()
的代码是否能保证独占执行。
- 这里的自增操作是否真正原子(不可分割)。
现代处理器采用了多种技术来提高性能,如超标量和超流水线执行、指令和内存重排序、复杂的CPU缓存等,这些都会影响操作的原子性。
要判断
i++
操作是否原子,可以通过以下步骤:
1. 打开编译器探索网站:https://godbolt.org/ 。
2. 选择C作为编程语言。
3. 在左窗格中声明全局整数
i
,并在函数中对其进行自增操作。
4. 在右窗格中使用适当的编译器和编译器选项进行编译。
5. 查看生成的实际机器代码。如果是单条机器指令,则操作是安全且原子的;否则,需要进行锁定保护。
通常,在基于CISC的机器(如x86[_64])上,编译器优化级别为2及以上时,代码可能会是原子的,但在基于RISC的机器(如ARM)上不一定。因此,默认情况下应假设
i++
操作是不安全的,需要进行保护。
例如,在没有优化的情况下,
i++
通常会变成三条机器指令:将
i
从内存加载到寄存器、自增操作、将寄存器的值存储回内存。这不是原子操作,可能会导致数据竞争。而使用
-O2
优化选项时,
i++
可能会变成单条机器指令,实现原子操作,但这并不总是可靠的。
4. 锁的概念
由于线程可以并发执行访问共享可写数据的临界区代码,因此需要进行同步,消除并行性,使临界区的代码串行执行。
一种常见的技术是使用锁。锁的基本原理是在任何时刻,只有一个线程可以“获取”或拥有锁,获取锁的线程成为“获胜者”,可以继续执行临界区的代码。其他线程则需要等待锁被释放。
锁的工作过程可以用以下流程图表示:
graph TD;
A[多个线程竞争锁] --> B{是否获取到锁};
B -- 是 --> C[执行临界区代码];
C --> D[释放锁];
D --> E[其他线程继续竞争锁];
B -- 否 --> F[等待锁释放];
F --> E;
使用锁可以保证临界区代码的独占执行,但会带来较大的开销,因为它消除了并行性,使执行流程串行化。锁就像一个漏斗,只有一个线程可以通过,其他线程需要等待,会造成瓶颈。因此,作为软件架构师,应尽量减少锁的使用,优化和减少全局变量的使用。
需要注意的是,新手程序员可能会认为对共享可写数据对象的读取操作是安全的,不需要显式保护,但实际上除了处理器总线大小内的对齐基本数据类型外,这种情况可能会导致脏读或撕裂读。
内核同步 - 第1部分
5. 锁的开销与优化思路
锁虽然能保证临界区代码的独占执行,但会带来显著的开销,就像前文提到的,它会破坏并行性,使执行流程串行化,产生瓶颈。为了更直观地理解,我们可以将其类比为高速公路上多车道合并为一条拥堵车道,或者一个狭窄的漏斗,只有一个线程能通过,其他线程只能等待。
为了减少锁带来的负面影响,软件架构师需要在设计产品或项目时尽量减少锁的使用。虽然在大多数实际项目中完全消除全局变量不太现实,但可以通过优化和减少其使用来降低锁的需求。以下是一些具体的思路:
-
优化全局变量使用
:尽量减少全局变量的数量,将数据封装在更小的作用域内,降低数据共享的范围。
-
使用锁粒度控制
:根据实际情况选择合适的锁粒度。如果锁的范围过大,会导致更多的线程等待,降低并发性能;如果锁的范围过小,会增加锁的管理开销。
-
探索无锁编程技术
:在某些场景下,可以使用无锁编程技术,避免使用锁带来的开销。例如,使用原子操作、CAS(Compare-And-Swap)操作等。
6. 不同类型锁的特点及适用场景
在实际编程中,有多种类型的锁可供选择,不同类型的锁具有不同的特点和适用场景。以下是一些常见的锁及其特点:
| 锁类型 | 特点 | 适用场景 |
| ---- | ---- | ---- |
| 互斥锁(Mutex) | 用于保护临界区,同一时间只允许一个线程访问。线程获取锁时如果锁已被占用,会进入阻塞状态。 | 适用于可能会睡眠的上下文,如在用户应用程序对驱动程序进行的典型文件操作中。 |
| 自旋锁(Spinlock) | 线程在获取锁时,如果锁已被占用,会不断自旋等待,不会睡眠。 | 适用于非阻塞的原子上下文,如硬件中断处理程序中,因为在这种上下文中不允许睡眠。 |
| 读写锁(Read-Write Lock) | 允许多个线程同时进行读操作,但在写操作时会独占锁。 | 适用于读多写少的场景,提高并发性能。 |
下面通过一个简单的示例来对比互斥锁和自旋锁的使用:
// 互斥锁示例
#include <linux/mutex.h>
static DEFINE_MUTEX(my_mutex);
void my_function()
{
mutex_lock(&my_mutex);
// 临界区代码
// ...
mutex_unlock(&my_mutex);
}
// 自旋锁示例
#include <linux/spinlock.h>
static spinlock_t my_spinlock;
void my_function_spin()
{
unsigned long flags;
spin_lock_irqsave(&my_spinlock, flags);
// 临界区代码
// ...
spin_unlock_irqrestore(&my_spinlock, flags);
}
在上述示例中,
my_function
使用了互斥锁,适用于可能会睡眠的上下文;
my_function_spin
使用了自旋锁,适用于非阻塞的原子上下文。
7. 锁的使用注意事项
在使用锁时,需要注意以下几点:
-
避免死锁
:死锁是指两个或多个线程相互等待对方释放锁,导致程序无法继续执行的情况。为了避免死锁,需要确保线程按照相同的顺序获取锁,或者使用超时机制。
-
锁的粒度控制
:如前文所述,需要根据实际情况选择合适的锁粒度,避免锁的范围过大或过小。
-
中断处理
:在中断处理程序中使用锁时,需要特别注意。例如,在使用自旋锁时,需要使用
spin_lock_irqsave
和
spin_unlock_irqrestore
来保存和恢复中断状态,避免中断处理程序和普通线程之间的竞争。
8. 总结
内核同步是多核系统编程中非常重要的一部分,尤其是在处理共享可写数据时,需要正确识别和保护临界区,避免数据竞争和损坏。锁是一种常用的同步机制,但会带来一定的开销,因此需要合理使用。
在实际编程中,需要根据不同的场景选择合适的锁类型,并注意锁的使用注意事项,避免出现死锁等问题。同时,还可以探索无锁编程技术,优化和减少全局变量的使用,提高程序的并发性能。
通过本文的介绍,相信你对内核同步的基本概念、锁的使用等有了更深入的理解。在后续的编程实践中,希望你能够灵活运用这些知识,编写出高效、稳定的代码。
超级会员免费看

被折叠的 条评论
为什么被折叠?



