一.并发与竞争
- 定义:并发是指多个程序同时访问一个共享资源。当这种情况发生时,由于同时访问而产生的就是竞争问题。
- 例子:例如A和B要打电话,但公共电话只有一部。因此,谁先打电话就形成了竞争。然而,电话是所有人都可以使用的,所以它被视为共享资源。
- 在Linux中的体现:Linux是一个多任务操作系统,其中并发和竞争非常常见。因此在编写Linux驱动程序时需要特别考虑这些问题,否则在访问共享资源时容易出错,而且这些错误往往难以排查和定位。
1.并发
CPU在工作时间内会依次开始执行任务一和任务二,但由于CPU的处理速度很快,它可以交替地执行这两个任务,使得它们看起来像是同时进行的。这种交替执行的方式就是并发。在实际的操作中,CPU可能会因为各种原因而暂停当前任务的执行,转而去执行其他任务,这被称为“上下文切换”。一旦条件允许,CPU会恢复到之前被暂停的任务上继续执行,直到所有任务都完成为止。
2.并行
并行处理通过将两个任务分配到双核CPU的两个独立内核上,实现了任务的同步执行。每个内核可以同时运行不同的任务,从而提高了整体的处理效率和速度。这种技术允许系统更有效地利用资源,减少了等待时间,增强了用户体验。
3.并发加并行
并发是指在一段时间内通过上下文切换使得多个任务快速交替执行,从而给人一种同时进行的错觉;而并行则是真正的同时执行多个任务。“双核CPU”可以同时在两个CPU上分别处理“任务一”和“任务二”,这就是并行。但同时,这两个任务又与其他的任务一起被安排和处理,体现了并发的特性。
并发带来的问题?
当有两个相同的驱动程序 A 和 B 并发执行且都需要修改变量 C时可能出现的情形。
- 情况1:
- 理想情况下,程序 A 先运行并在程序 B 运行前完成。此时,程序 A 和 B 完美运行,没有错误。
- 情况2:
- 程序 A 运行了一半,然后让程序 B 执行。B 执行完毕后返回执行 A。但是,在这种情况下,程序 B 的执行相当于什么也没做,因为最终变量 c 的值仍然是程序 A 设置的值。
- 总结:
- 虽然情况1是理想的,但我们无法预测程序 A 和 B 将会如何实际运行。如果不保护共享资源,可能会导致程序行为异常甚至系统崩溃。
Linux操作系统中导致并发访问的几种情况:
- 中断程序的并发访问:中断可以随时产生,一旦出现中断,当前正在执行的程序会被暂时挂起去处理中断任务。如果在处理中断的过程中修改了共享资源,就会引发并发问题。
- 抢占式并发访问:从Linux内核2.6版本开始,Linux支持抢占机制。这意味着任何时刻运行的进程都有可能被其他高优先级的进程抢占,从而可能导致对共享资源的并发访问。
- 多处理器(SMP)并发访问:在现代多核处理器架构下,不同的CPU核心可能会同时对同一个共享资源进行访问,这也会导致并发问题。
这些情况说明了在多任务、多处理器环境下,如何有效地管理和同步对共享资源的访问是保证系统稳定性和正确性的关键。
二.原子操作
原子操作中的“原子”指的是化学反应中最小的微粒。在Linux上用原子形容一个操作或者一个函数是最小执行单位,是不可以被打断的。所以原子操作指的是该操作在执行完之前不会被任何事物打断。原子操作的目的是为了确保数据的完整性,防止出现竞态条件(Race Condition),即多个进程或线程同时对同一数据进行读写操作时可能导致的错误结果。原子操作一般用于整形变量或者位的保护。比我,我们定义一个变量a,如果程序A正在给变量a赋值,此时程序B也要来操作变量a,这时候就发生了并发与竞争。程序A的操作就有可能会被程序B打断。如果我们使用原子操作对变量a进行保护,就可以避免这种问题
它定义了两个结构体atomic_t和atomic64_t来描述原子变量。其中,atomic_t用于32位系统中,而atomic64_t则用于64位系统中。以下是具体的代码实现:
typedef struct {int counter;
} atomic_t;
#if defined(CONFIG_64BIT)
typedef struct {long counter;
} atomic64_t;
#endif
这段代码首先定义了一个简单的整数计数器counter的结构体atomic_t,适用于32位系统。然后通过宏CONFIG_64BIT判断是否为64位系统,如果是的话,就定义另一个结构体atomic64_t,其计数器类型为long,以适应64位系统的需求。
atomic64_tV=ATOMIC64 INIT(0);//定义并初始化原子变量v=0
atomic64_set(&v,1);//设置v=1
atomic64_read(&v); ///读取v的值,此时v的值是1
atomic64_inc(&v); //v的值加1,此时v的值是2
三.自旋锁
自旋锁是一种用于保护共享资源的锁机制,特别是在多线程或并发编程场景中。它的基本原理是通过“原地等待”的方式来解决资源冲突。当一个线程获取到自旋锁后,其他试图获取同一把锁的线程不会进入休眠状态,而是会在一个循环中不断地检查锁的状态,直到能够成功获得锁为止。这种方式避免了上下文切换的开销,但会占用CPU资源,因为它不让出CPU的使用权。自旋锁既有针对中断的一套API,并且在中断中也可以使用自旋锁。
Linux内核中spinlock_t结构的定义:
typedef struct spinlock {union {struct raw_spinlock rlock;#ifdef CONFIG_DEBUG_LOCK_ALLOCstruct {u8 __padding[LOCK_PADSIZE];struct lockdep_map dep_map;};#endif /* CONFIG_DEBUG_LOCK_ALLOC */};
} spinlock_t;
自旋锁注意事项:
- 原地等待:由于自旋锁会“原地等待”,这意味着当线程尝试获取锁但未成功时,它会在一个循环中不断检查锁的状态直到获得锁。这种机制会持续占用CPU资源,因此持锁时间不能太长,临界区的代码量应尽量少。
- 避免休眠或阻塞操作:在持有自旋锁的临界区内,不应调用可能导致线程进入休眠状态的操作(如某些I/O操作)。这是因为一旦线程休眠,自旋锁将无法释放,从而可能导致死锁或其他同步问题。
- 适用场景:自旋锁通常适用于多核系统,特别是在那些需要快速响应且等待锁的时间很短的场景中。它们不适用于单核系统或在等待锁的过程中可能发生长时间延迟的情况。
总结来说,自旋锁适合于短时间的、轻量级的同步需求,但在使用时必须小心以避免过度消耗CPU资源和潜在的死锁风险。
四.信号量
信号量的引入是为了更有效地管理资源的并发与竞争。在操作系统中,自旋锁通过“原地等待”的方式来处理资源竞争,但这种方式可能会浪费CPU资源,尤其是在需要长时间等待的情况下。
因此,信号量应运而生。它允许在一个进程等待资源时将其暂时挂起,从而避免CPU空转。这就像一个电话亭只有一个公共电话,当一个人在使用电话时,其他人需要等待。如果是自旋锁机制,其他人都必须一直轮询检测电话是否可用,这样会造成不便和资源浪费。而信号量则可以让第一个使用者占用电话,同时通知其他等待者稍等片刻,待其使用完毕后再依次让后续的人使用。
这种机制提高了系统的效率,因为它减少了不必要的资源消耗,并在多个任务间提供了更好的协作方式。这也是为什么信号量有时被称为“睡眠锁”,因为线程在等待时会进入休眠状态,而不是持续地消耗CPU资源。
信号量的工作方式:
- 信号量本质:信号量本质上是一个全局变量,它的值可以根据实际情况自行设置(取值范围大于等于0)。
- 访问控制:当有线程来访问资源时,信号量执行“减一”操作;访问完成以后,再执行“加一”操作。
信号量注意事项:
- 信号量的值不能小于0;
- 访问共享资源时,信号量执行“减一”操作,访问完成后执行“加一”操作;
- 当信号量的值为0时,想访问共享资源的线程必须等待,直到信号量大于0时,等待的线程才可以访问;
- 因为信号量会引起休眠,所以中断里面不能用信号量;
- 共享资源持有时间比较长,一般用信号量而不用自旋锁;
- 在同时使用信号量和自旋锁的时候,要先获取信号量,在使用自旋锁。因为信号量会导致睡眠。
- 这些注意事项旨在确保正确、安全地使用信号量进行多线程编程中的同步和互斥控制
五.互斥锁
同一资源在同一时间内只能有一个访问者在访问,其他的访问者访问结束以后才可以访问这个资源,这就是互斥。互斥锁和信号量为1的情况很类似,但是互斥锁更加简洁高效。虽然互斥锁有优点,但在使用过程中需要注意的事项也更多(互斥锁也会引起休眠)。
互斥锁注意事项:
- 互斥锁会导致休眠,所以中断里面不能用互斥锁。
- 同一时刻只能有一个线程持有互斥锁,并且只有持有者可以解锁。
- 不允许递归上锁和解锁。