目录
1 编译乱序与执行乱序
由于编译器和处理器的特点,程序在编译或者执行时容易发生“乱序”。一种是编译乱序,另一种是执行乱序。
1.1 编译乱序
编译乱序指的就是编译器在编译过程中因为本身的编译优化,可能不会按照代码的编写顺序进行编译,从而导致“乱序”编译。
原理就是:现代的高性能编译器在目标代码优化上都具备对指令进行乱序优化的能力。编译器可以对访存的指令进行乱序处理,减少逻辑上的不必要的访存,以尽量提高Cache命中率和CPU的Load/Store单元的工作效率。
处理方式:在代码中设置barrier()编译屏障,编译器就会按序进行编译。
例如:
//比如, 下面的一段代码在e=d[4095]与b=a、 c=a之间没有编译屏障:
int main(int argc, char *argv[])
{
int a = 0, b, c, d[4096], e;
e = d[4095];
b = a;
c = a;
printf("a:%d b:%d c:%d e:%d\n", a, b, c, e);
return 0;
}
//用“arm-linux-gnueabihf-gcc-O2”优化编译, 反汇编结果是
int main(int argc, char *argv[])
{
831c: b530 push {r4, r5, lr}
831e: f5ad 4d80 sub.w sp, sp, #16384 ; 0x4000
8322: b083 sub sp, #12
8324: 2100 movs r1, #0
8326: f50d 4580 add.w r5, sp, #16384 ; 0x4000
832a: f248 4018 movw r0, #33816 ; 0x8418
832e: 3504 adds r5, #4
8330: 460a mov r2, r1 -> b= a;
8332: 460b mov r3, r1 -> c= a;
8334: f2c0 0000 movt r0, #0
8338: 682c ldr r4, [r5, #0]
833a: 9400 str r4, [sp, #0] -> e = d[4095]; //尽管源代码级别b=a、 c=a发生在e=d[4095]之后, 但是目标代码的b=a、 c=a指令发生在e=d[4095]之前
833c: f7ff efd4 blx 82e8 <_init+0x20>
}
//重新编写代码, 在e=d[4095]与b=a、 c=a之间加上编译屏障:
#define barrier() __asm__ __volatile__("": : :"memory")
int main(int argc, char *argv[])
{
int a = 0, b, c, d[4096], e;
e = d[4095];
barrier();
b = a;
c = a;
printf("a:%d b:%d c:%d e:%d\n", a, b, c, e);
return 0;
}
//再次用“arm-linux-gnueabihf-gcc-O2”优化编译, 反汇编结果是:
int main(int argc, char *argv[])
{
831c: b510 push {r4, lr}
831e: f5ad 4d80 sub.w sp, sp, #16384 ; 0x4000
8322: b082 sub sp, #8
8324: f50d 4380 add.w r3, sp, #16384 ; 0x4000
8328: 3304 adds r3, #4
832a: 681c ldr r4, [r3, #0]
832c: 2100 movs r1, #0
832e: f248 4018 movw r0, #33816 ; 0x8418
8332: f2c0 0000 movt r0, #0
8336: 9400 str r4, [sp, #0] -> e = d[4095];
8338: 460a mov r2, r1 -> b= a;
833a: 460b mov r3, r1 -> c= a;
833c: f7ff efd4 blx 82e8 <_init+0x20>
}
1.2 执行乱序
执行乱序指的是程序在执行过程中,没有按照编译生成的二进制指令顺序依次执行指令,从而导致执行“乱序”。
原理就是:高级的CPU可以根据自己缓存的组织特性,将访存指令进行乱序优化。连续地址的访问可能优先执行,为了缓存命中率高。有的CPU还允许非阻塞式访存,即如果前一条指令因为缓存未命名,造成长延时的存储访问时,后面的指令会优先执行。
处理方式:处理器提供的内存屏蔽指令(ARM为例):
- DMB(数据内存屏障):在DMB之后的显式内存访问执行前, 保证所有在DMB指令之前的内存访问完成;
- DSB(数据同步屏障):等待所有在DSB指令之前的指令完成( 位于此指令前的所有显式内存访问均完成, 位于此指令前的所有缓存、 跳转预测和TLB维护操作全部完成) ;
- ISB(指令同步屏障):Flush流水线, 使得所有ISB之后执行的指令都是从缓存或内存中获得的。
2 中断屏蔽
linux2.6.35后取消了中断嵌套。
中断屏蔽:即在进程进入临界区之前屏蔽系统的中断,从而避免竟态(并发的执行单元竞争临界资源)。具体来说:中断屏蔽可以避免中断与进程间的并发,同时由于linux内核的进程调度也是通过中断实现,因此也能解决抢占进程间的并发。
中断屏蔽的使用方法:
local_irq_disable() // 屏蔽中断
......
critical section() // 临界区
......
local_irq_enable() // 打开中断
=================================================================
local_irq_save(flags) // 屏蔽中断,但会保存目前cpu的中断位信息
......
critical section() // 临界区
......
local_irq_restore(flags) //保存和恢复CPSR
只禁止终端底半部:
local_bh_disable() // 屏蔽中断的底半部
local_bh_enable() // 使能底半部中断
3 原子操作
保证对一个整型数据的修改是排他性的。Linux内核提供了一系列函数来实现内核中的
原子操作, 这些函数又分为两类, 分别针对位和整型变量进行原子操作。
3.1 整型原子操作
3.1.1 设置原子变量的值
atomic_t v = ATOMIC_INIT(0); // 定义原子变量v,并初始化为0
void atomic_set(atomic_t *v, int i); // 设置原子变量的值为i
3.1.2 获取原子变量的值
atomic_read(atomic_t *v); // 返回原子变量的值
3.1.3 原子变量的加减操作
void atomic_add(int i, atomic_t *v); // 原子变量加i
void atomic_sub(int i, atomic_t *v); // 原子变量减i
3.3.4 原子变量的自增和自减
void atomic_inc(atomic_t *v); // 原子变量加1
void atomic_dec(atomic_t *v); // 原子变量减1
3.3.5 原子变量的操作和测试
int atomic_inc_and_test(atomic_t *v); // 原子变量v加一后判断v是否为0,为0返回1(true),否则返回0(false)
int atomic_dec_and_test(atomic_t *v); // 原子变量v减一后判断v是否为0,为0返回1(true),否则返回0(false)
int atomic_sub_and_test(int i, atomic_t *v); // 原子变量v减i后判断v是否为0,为0返回1(true),否则返回0(false)
3.3.6 原子变量的操作并返回
int atomic_add_return(int i, atomic_t *v); // 原子变量v加i,返回新的原子变量的值
int atomic_sub_return(int i, atomic_t *v); // 原子变量v减i,返回新的原子变量的值
int atomic_inc_return(atomic_t *v); // 原子变量v加1,返回新的原子变量的值
int atomic_dec_return(atomic_t *v); // 原子变量v减1,返回新的原子变量的值
3.2 位原子操作
3.2.1 设置位
void set_bit(nr, void *addr); // 设置addr的nr位为1
3.2.2 清除位
void clear_bit(nr, void *addr); // 设置addr的nr位为0
3.2.3 改变位
void change_bit(nr, void *addr); // 对addr的第nr位取反
3.2.4 测试位
test_bit(nr, void *addr); // 返回addr地址的nr位
3.2.5 测试并操作位
int test_and_set_bit(nr, void *addr); // 等同于执行test_bit()后再执行set_bit()
int test_and_clear_bit(nr, void *addr); // 等同于执行test_bit()后再执行clear_bit()
int test_and_change_bit(nr, void *addr); // 等同于执行test_bit()后再执行change_bit()
举例:使用原子变量使设备只能被一个进程打开
static atomic_t xxx_available = ATOMIC_INIT(1); /* 定义原子变量*/
static int xxx_open(struct inode *inode, struct file *filp)
{
...
if (!atomic_dec_and_test(&xxx_available)) {
atomic_inc(&xxx_available);
return - EBUSY; /* 已经打开*/9
}
...
return 0; /* 成功 */
}
static int xxx_release(struct inode *inode, struct file *filp)
{
atomic_inc(&xxx_available); /* 释放设备 */
return 0;
}
4 自旋锁
通过原子操作实现,在某CPU上执行的代码需要先执行一个原子操作,该操作测试并设置某个内存变量。执行单元想要访问这个内存变量需要先测试并返回内存变量的结果判断是否空闲,如果测试结果表明被占用,则该执行单元会循环测试并设置,即自旋。
理解自旋锁最简单的方法是把它作为一个变量看待, 该变量把一个临界区标记为“我当前在运行, 请稍等一会”或者标记为“我当前不在运行, 可以被使用”。 如果A执行单元首先进入例程, 它将持有自旋锁;当B执行单元试图进入同一个例程时, 将获知自旋锁已被持有, 需等到A执行单元释放后才能进入。
4.1 自旋锁的使用
4.1.1 定义自旋锁
spinlock_t lock;
4.1.2 初始化自旋锁
spin_lock_init(lock);
4.1.3 获得自旋锁
spin_lock(lock); // 获得自旋锁,如果能获得自旋锁立马返回,得不到就循环自旋
spin_trylock(lock); // 获得自旋锁,如果能获得自旋锁则返回true,得不到就返回false,不会自旋
4.1.4 释放自旋锁
spin_unlock(lock); // 释放自旋锁
4.2 衍生自旋锁
虽然自旋锁可以保证临界区不受其他CPU和本CPU的抢占进程打扰,但无法避免中断和底半部的影响,因此推出了衍生自旋锁。衍生自旋锁本质上就是自旋锁和中断屏蔽的组合使用。
spin_lock_irq() == spin_lock() + local_irq_disable();
spin_unlock_irq() == spin_unlock() + local_irq_enable();
spin_lock_irqsave() == spin_lock() + local_irq_save();
spin_unlock_irqrestore() == spin_unlock() + local_irq_restore();
spin_lock_bh() == spin_lock() + local_bh_disable();
spin_unlock_bh() == spin_unlock() + local_bh_enable();
自旋锁的注意事项:
1) 自旋锁实际上是忙等锁, 当锁不可用时, CPU一直循环执行“测试并设置”该锁直到可用而取得该锁, CPU在等待自旋锁时不做任何有用的工作, 仅仅是等待。 因此, 只有在占用锁的时间极短的情况下,使用自旋锁才是合理的。 当临界区很大, 或有共享设备的时候,需要较长时间占用锁, 使用自旋锁会降低系统的性能。
2) 自旋锁可能导致系统死锁。 引发这个问题最常见的情况是递归使用一个自旋锁, 即如果一个已经拥有某个自旋锁的CPU想第二次获得这个自旋锁, 则该CPU将死锁。
3) 在自旋锁锁定期间不能调用可能引起进程调度的函数。 如果进程获得自旋锁之后再阻塞, 如调用copy_from_user( ) 、 copy_to_user( ) 、 kmalloc( ) 和msleep( ) 等函数, 则可能导致内核的崩溃。
4) 在单核情况下编程的时候, 也应该认为自己的CPU是多核的, 驱动特别强调跨平台的概念。 比如, 在单CPU的情况下, 若中断和进程可能访问同一临界区, 进程里调用spin_lock_irqsave( ) 是安全的, 在中断里其实不调用spin_lock( ) 也没有问题, 因为spin_lock_irqsave( ) 可以保证这个CPU的中断服务程序不可能执行。 但是, 若CPU变成多核, spin_lock_irqsave() 不能屏蔽另外一个核的中断, 所以另外一个核就可能造成并发问题。 因此, 无论如何, 我们在中断服务程序里也应该调用spin_lock() 。
例如,使用自旋锁使设备只能被一个设备打开
int xxx_count = 0;/* 定义文件打开次数计数*/
static int xxx_open(struct inode *inode, struct file *filp)
{
...
spinlock(&xxx_lock);
if (xxx_count) {/* 已经打开*/
spin_unlock(&xxx_lock);
return -EBUSY;
}
xxx_count++;/* 增加使用计数*/
spin_unlock(&xxx_lock);
...
return 0;/* 成功 */
}
static int xxx_release(struct inode *inode, struct file *filp)
{
...
spinlock(&xxx_lock);
xxx_count--;/* 减少使用计数*/
spin_unlock(&xxx_lock);
return 0;
}
4.3 读写自旋锁
自旋锁不关心锁定的临界区究竟在进行什么操作, 不管是读还是写, 它都一视同仁。 即便多个执行单元同时读取临界资源也会被锁住。
读写自旋锁是一种比自旋锁粒度更小的锁机制, 它保留了“自旋”的概念, 但是在写操作方面, 只能最多有1个写进程, 在读操作方面, 同时可以有多个读执行单元。 读和写也不能同时进行。
4.3.1 定义和初始化读写自旋锁
rwlock_t my_rwlock;
rwlock_init(&my_rwlock); /* 动态初始化 */
4.3.2 读锁定
void read_lock(rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock, unsigned long flags);
void read_lock_irq(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
4.3.3 读解锁
void read_unlock(rwlock_t *lock);
void read_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void read_unlock_irq(rwlock_t *lock);
void read_unlock_bh(rwlock_t *lock);
4.4.4 写锁定
void write_lock(rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock, unsigned long flags);
void write_lock_irq(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);
int write_trylock(rwlock_t *lock);
4.3.5 写解锁
void write_unlock(rwlock_t *lock);
void write_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void write_unlock_irq(rwlock_t *lock);
void write_unlock_bh(rwlock_t *lock);
举例使用读写自旋锁
rwlock_t lock; /* 定义rwlock */
rwlock_init(&lock); /* 初始化rwlock */
read_lock(&lock); /* 读时获取锁*/
... /* 临界资源 */
read_unlock(&lock);
write_lock_irqsave(&lock, flags);/* 写时获取锁*/
... /* 临界资源 */
write_unlock_irqrestore(&lock, flags);
4.4 顺序锁
顺序锁(seqlock) 是对读写锁的一种优化。 读操作不会被写操作阻塞, 也就是说, 被顺序锁保护的共享资源进行写操作时仍然可以继续读, 而不必等待写执行单元完成写操作, 写执行单元也不需要等待所有读执行单元完成读操作才去进行写操作。 但是, 写执行单元与写执行单元之间仍然是互斥的, 即如果有写执行单元在进行写操作, 其他写执行单元必须自旋在那里, 直到写执行单元释放了顺序锁。
对于顺序锁而言, 尽管读写之间不互相排斥, 但是如果读执行单元在读操作期间, 写执行单元发生了写操作, 那么, 读执行单元必须重新读取数据, 以便确保得到的数据是完整的。 所以, 在这种情况下, 读端可能反复读多次同样的区域才能读到有效的数据。
写执行单元涉及的顺序锁操作如下:
4.4.1 获得顺序锁(写)
void write_seqlock(seqlock_t *sl);
int write_tryseqlock(seqlock_t *sl);
write_seqlock_irqsave(lock, flags) //write_seqlock_irqsave() = loal_irq_save() + write_seqlock()
write_seqlock_irq(lock) //write_seqlock_irq() = local_irq_disable() + write_seqlock()
write_seqlock_bh(lock) //write_seqlock_bh() = local_bh_disable() + write_seqlock()
4.4.2 释放顺序锁(写)
void write_sequnlock(seqlock_t *sl);
write_sequnlock_irqrestore(lock, flags) //write_sequnlock_irqrestore() = write_sequnlock() + local_irq_restore()
write_sequnlock_irq(lock) //write_sequnlock_irq() = write_sequnlock() + local_irq_enable()
write_sequnlock_bh(lock) //write_sequnlock_bh() = write_sequnlock() + local_bh_enable()
4.4.3 顺序锁的使用(写)
//写执行单元使用顺序锁的模式如下,对写执行单元而言,它的使用与自旋锁相同:
write_seqlock(&seqlock_a);
.../* 写操作代码块 */
write_sequnlock(&seqlock_a);
读执行单元涉及的顺序锁操作如下:
4.4.4 读开始
读执行单元在对被顺序锁s1保护的共享资源进行访问前需要调用该函数, 该函数返回顺序锁s1的当前顺序号。
unsigned read_seqbegin(const seqlock_t *sl);
read_seqbegin_irqsave(lock, flags) //read_seqbegin_irqsave() = local_irq_save() + read_seqbegin()
4.4.5 重读
读执行单元在访问完被顺序锁s1保护的共享资源后需要调用该函数来检查, 在读访问期间是否有写操作。 如果有写操作, 读执行单元就需要重新进行读操作。
int read_seqretry(const seqlock_t *sl, unsigned iv);
read_seqretry_irqrestore(lock, iv, flags) //read_seqretry_irqrestore() = read_seqretry() + local_irq_restore()
4.4.6 顺序锁的使用(读)
do {
seqnum = read_seqbegin(&seqlock_a);
/* 读操作代码块 */
...
} while (read_seqretry(&seqlock_a, seqnum));
4.5 RCU 读-复制-更新
不同于自旋锁, 使用RCU的读端没有锁几乎可以认为是直接读(只是简单地标明读开始和读结束),而RCU的写执行单元在访问它的共享资源前首先复制一个副本,然后对副本进行修改, 最后使用一个回调机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据, 这个时机就是所有引用该数据的CPU都退出对共享数据读操作的时候。 等待适当时机的这一时期称为宽限期(Grace Period) 。
RCU可以看作读写锁的高性能版本, 相比读写锁, RCU的优点在于既允许多个读执行单元同时访问被保护的数据, 又允许多个读执行单元和多个写执行单元同时访问被保护的数据。 但是, RCU不能替代读写锁, 因为如果写比较多时, 对读执行单元的性能提高不能弥补写执行单元同步导致的损失。 因为使用RCU时, 写执行单元之间的同步开销会比较大, 它需要延迟数据结构的释放, 复制被修改的数据结构,它也必须使用某种锁机制来同步并发的其他写执行单元的修改操作。
Linux中提供的RCU操作包括如下4种
4.5.1 读锁定
rcu_read_lock()
rcu_read_lock_bh()
4.5.2 读解锁
rcu_read_unlock()
rcu_read_unlock_bh()
4.5.3 同步RCU
synchronize_rcu()
该函数由RCU写执行单元调用, 它将阻塞写执行单元,直到当前CPU上所有的已经存在(Ongoing)的读执行单元完成读临界区,写执行单元才可以继续下一步操作。 synchronize_rcu() 并不需要等待后续(Subsequent) 读临界区的完成,
4.5.4 挂接回调
void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *rcu));
函数call_rcu() 也由RCU写执行单元调用, 与synchronize_rcu() 不同的是, 它不会使写执行单元阻塞, 因而可以在中断上下文或软中断中使用。 该函数把函数func挂接到RCU回调函数链上, 然后立即返回。 挂接的回调函数会在一个宽限期结束(即所有已经存在的RCU读临界区完成) 后被执行。
5 信号量
信号量(Semaphore) 是操作系统中最典型的用于同步和互斥的手段, 信号量的值可以是0、 1或者n。信号量与操作系统中的经典概念PV操作对应。
P(S):
①将信号量S的值减1, 即S=S-1;
②如果S≥0, 则该进程继续执行; 否则该进程置为等待状态, 排入等待队列。
V(S):
①将信号量S的值加1, 即S=S+1;
②如果S>0, 唤醒队列中等待信号量的进程。
5.1 信号量操作
5.1.1 定义信号量
struct semaphore sem;
5.1.2 初始化信号量
void sema_init(struct semaphore *sem, int val);
5.1.3 获得信号量
void down(struct semaphore * sem); //它会导致睡眠,进入睡眠状态的进程不能被信号打断,因此不能在中断上下文中使用
int down_interruptible(struct semaphore * sem);
// 进入睡眠状态的进程能被信号打断,信号也会导致该函数返回, 这时候函数的返回值非0。
// 在使用down_interruptible() 获取信号量时, 对返回值一般会进行检查, 如果非0,通常立即返回-ERESTARTSYS
if (down_interruptible(&sem))
return -ERESTARTSYS;
int down_trylock(struct semaphore * sem); //如果能够立刻获得,它就获得该信号量并返回0,否则, 返回非0值。它不会导致调用者睡眠, 可以在中断上下文中使用。
5.1.4 释放信号量
void up(struct semaphore * sem);
作为一种互斥手段,信号量可以保护临界区, 它的使用方式和自旋锁类似。 与自旋锁相同, 只有得到信号量的进程才能执行临界区代码。 但是, 与自旋锁不同的是, 当获取不到信号量时, 进程不会原地打转而是进入休眠等待状态。由于新的Linux内核倾向于直接使用mutex作为互斥手段, 信号量用作互斥不再被推荐使用。
信号量也可以用于同步, 一个进程A执行down() 等待信号量, 另外一个进程B执行up() 释放信号量, 这样进程A就同步地等待了进程B。
6 互斥体
6.1 互斥体的使用
6.1.1 定义并初始化互斥体
struct mutex my_mutex;
mutex_init(&my_mutex);
6.1.1 获取互斥体
void mutex_lock(struct mutex *lock); //引起的睡眠不能被信号打断
int mutex_lock_interruptible(struct mutex *lock); //引起的睡眠能被信号打断
int mutex_trylock(struct mutex *lock); //获取不到时不会引起进程睡眠
6.1.2 释放互斥体
void mutex_unlock(struct mutex *lock);
6.1.3 互斥体的使用
struct mutex my_mutex; /* 定义mutex */
mutex_init(&my_mutex); /* 初始化mutex */
mutex_lock(&my_mutex); /* 获取mutex */
... /* 临界资源*/
mutex_unlock(&my_mutex); /* 释放mutex */
6.2 自旋锁与互斥体的选择
选择的依据是临界区的性质和系统的特点。
从严格意义上说, 互斥体和自旋锁属于不同层次的互斥手段, 前者的实现依赖于后者。 在互斥体本身的实现上, 为了保证互斥体结构存取的原子性, 需要自旋锁来互斥。 所以自旋锁属于更底层的手段。
互斥体是进程级的, 用于多个进程之间对资源的互斥, 虽然也是在内核中, 但是该内核执行路径是以进程的身份, 代表进程来争夺资源的。 如果竞争失败, 会发生进程上下文切换, 当前进程进入睡眠状态,CPU将运行其他进程。 鉴于进程上下文切换的开销也很大, 因此, 只有当进程占用资源时间较长时,用互斥体才是较好的选择。
当所要保护的临界区访问时间比较短时, 用自旋锁是非常方便的, 因为它可节省上下文切换的时间。但是CPU得不到自旋锁会在那里空转直到其他执行单元解锁为止, 所以要求锁不能在临界区里长时间停留, 否则会降低系统的效率。
自旋锁和互斥体选用的3项原则
1) 当锁不能被获取到时, 使用互斥体的开销是进程上下文切换时间, 使用自旋锁的开销是等待获取自旋锁(由临界区执行时间决定) 。 若临界区比较小, 宜使用自旋锁, 若临界区很大, 应使用互斥体。
2) 互斥体所保护的临界区可包含可能引起阻塞的代码, 而自旋锁则绝对要避免用来保护包含这样代码的临界区。 因为阻塞意味着要进行进程的切换, 如果进程被切换出去后, 另一个进程企图获取本自旋锁, 死锁就会发生。
3) 互斥体存在于进程上下文, 因此, 如果被保护的共享资源需要在中断或软中断情况下使用, 则在互斥体和自旋锁之间只能选择自旋锁。 当然, 如果一定要使用互斥体, 则只能通过mutex_trylock() 方式进行, 不能获取就立即返回以避免阻塞。
7 完成量
Completion用于一个执行单元等待另一个执行单元执行完某事。
7.1 完成量的使用
7.1.1 定义完成量
struct completion my_completion;
7.1.2 初始化完成量
init_completion(&my_completion); //初始化完成量的值为0(即没有完成的状态)
reinit_completion(&my_completion) //重新初始化完成量的值为0(即没有完成的状态)
7.1.3 等待完成量
void wait_for_completion(struct completion *c); //等待一个完成量被唤醒
7.1.4 唤醒完成量
void complete(struct completion *c); //唤醒一个等待的执行单元
void complete_all(struct completion *c); //释放所有等待同一完成量的执行单元