Linux中的race condition方案
什么时候需要解决race condition问题
- 多个进程同时访问同一资源时,比如多个进程同时访问共享内存。
- 多线程编程时,多个线程同时访问某些资源。
名词
- critical section: code that can be excuted by only one thead at any given time. Linux Device Drivers, Third Edition
- atomic context: an atomic context is sim- ply a state where multiple steps must be performed without any sort of concurrent access.
常用的几种方法
信号量和互斥锁
LDD3: Chapter 5: Concurrency and Race Conditions 中说得很清楚:
Semaphores are a well-understood concept in computer science. At its core, a sema- phore is a single integer value combined with a pair of functions that are typically called P and V. A process wishing to enter a critical section will call P on the relevant semaphore; if the semaphore’s value is greater than zero, that value is decremented by one and the process continues. If, instead, the semaphore’s value is 0 (or less), the process must wait until somebody else releases the semaphore. Unlocking a sema- phore is accomplished by calling V; this function increments the value of the sema- phore and, if necessary, wakes up processes that are waiting. When semaphores are used for mutual exclusion—keeping multiple processes from running within a critical section simultaneously—their value will be initially set to 1 . Such a semaphore can be held only by a single process or thread at any given time. A semaphore used in this mode is sometimes called a mutex, which is, of course, an abbreviation for “mutual exclusion.” Almost all semaphores found in the Linux ker- nel are used for mutual exclusion.
- 信号量根本上就是一个整数个两个操作:P,V。
- 如果整数大于0,需要访问临界资源的线程调P,对整数減一,然后进入临界区操作。
- 如果整数小于或等于0,需要访问临界资源的线程等待,直到该整数大于0。
- 线程访问完临界区后,调V,为整数加1,如果有的话,唤醒一个之前在P等待资源的线程。
- 初始化整数为1时,其实就是互斥锁啦。使用互斥锁,一次只可能有一个线程访问临界区。
- 我认为,《UNIX环境高级编程》一书中,线程同步中的条件变量,应该就是这种信号量。 条件变量
读写信号量
正如 读写锁 一节讲的,内核中也有读写信号量 struct rw_semaphore。
Completions
有这类情况:线程A需要等待线程B执行后,才往下执行。使用信号量完全可以实 现上述情形的同步。不过,这种情况下使用信号量又感觉性能上不太好。因为此 时线程A可以确定一定会在信号量上等待,直到线程B执行完后释放信号量。
于是内核引入了Completions。它就是简直的让一个线程告诉另一个线程,事情搞 定了。
Completion是一次性的,用完就完了。如果需要再用,就需要重新初始化一下。
Spinlocks(自旋锁?)
自旋锁的意思是,线程A首先拿到这个锁,然后执行。线程B拿不到这个锁时,就 在那儿等(自旋一会儿),还是占着cpu的(如果是信号量的话,可能它们这时 候就不占用cpu,可以sleep啦。)。注意这里A和B是在不同的cpu上执行的。等A 执行完后,B马上就可以执行。
一般情况下,自旋锁是效率比信号量高,因为一般被锁住的资源会很快被释放的。 但是它也有限制条件。
- 获得自旋锁后的操作,一般来说需要是原子操作,它们不能sleep。也就是 说不能在获得了自旋锁后,又放弃了cpu。如果出现这种情况,别的程序来获取 自旋锁时,永远都会“自旋”啦。但是可能导致sleep的操作很多,比如 copy_to_user,kmalloc等等。在使用自旋锁的时候,需要很小心。
- 自旋锁的代码中已经处理了抢断。在程序获得了自旋锁后,在执行该程序的 cpu上,抢断会被关闭。使得此时该程序在获得自旋锁后可以一直占着该cpu。
- 自旋锁还有接口,可以在获得自旋锁前,关闭该cpu上的中断。目的也是使 得此时该程序在获得自旋锁后可以一直占着该cpu
- 还有一个原则是,自旋锁应该尽可能少得的被占用。因为占用时间越多,可 能等待的线程就越多。浪费的cpu也就越多。
总的来说,自旋锁可以在支持抢断式的单核,或者多核系统上使用。在不支持抢 断式的单核设备上不能使用自旋锁。维基百科是这样说的:
显然,单核CPU不适于使用自旋锁,这里的单核CPU指的是单核单线程的CPU,因为,在同一时间只有一个线程是处在运行状态,假设运行线程A发现无法获取锁,只能等待解锁,但因为A自身不挂起,所以那个持有锁的线程B没有办法进入运行状态,只能等到操作系统分给A的时间片用完,才能有机会被调度。这种情况下使用自旋锁的代价很高。
自旋锁之前有一个问题,如果核很多,同时等待临界资源的线程也很多,最坏的 情况下,有一个线程总是得不到资源,需要等待很久。于是后面有人给自旋锁加 入了排队机制: Linux 内核的排队自旋锁(FIFO Ticket Spinlock)
问题:一般的锁,如果锁不住,会把cpu还给内核让它调度后面的线程吗?我现在理解应该是会的。
Linux Device Drivers, Third Edition 上说得很清楚:
an atomic context is simply a state where multiple steps must be performed without any sort of concurrent access. What that means, with regard to sleeping, is that your driver cannot sleep while holding a spinlock, seqlock, or RCU lock. You also cannot sleep if you have disabled interrupts. It is legal to sleep while holding a semaphore, but you should look very carefully at any code that does so. If code sleeps while holding a sema- phore, any other thread waiting for that semaphore also sleeps. So any sleeps that happen while holding semaphores should be short, and you should convince your- self that, by holding the semaphore, you are not blocking the process that will even- tually wake you up.在得到一个信号量后,是可以sleep的,也就是放弃cpu。其它等待该信号量的线 程也会sleep。其它比如自旋锁,是不行的。
读写自旋锁
和 读写信号量 一样,也有读写自旋锁。
读写锁
临界资源,有多个读和一个写时,这是比较有用的。没有写的线程时,多个读可 以同时进行。有写的线程时,读和写都必须等待。
条件变量 (这个是不是就是上面的信号量?)
在《UNIX环境高级编程》一书中,条件变量就是对应的P,V操作吧。一个线程V出 来一个资源,就可以唤醒一个等待的线程来P它。条件变量和互斥锁一起使用, 可以实现线程间的同步。
无锁算法
可以通过一些设计优良的算法来避免使用锁。比如 circular buffer(循环队 列)。它用于生产消费模型,不过只有一个reader和一个writer。writer不停得 往队尾写数据,reader不停地从队首读数据。仔细编写地话,这样的一个模型就可以不用锁。
原子操作
原子操作是意思是把对临界资源的操作全部放入api接口内部实现,这样就不需 要加锁了。Linux实现方式依赖于平台,每个平台都不一样,有些平台是直接用 汇编语言实现的,有些平台则是通过关中断的方法来实现的(如何通过关中断来实现这里我不太清楚)。
当需要保护的数据比较少的时候,使用原子操作是比较好的。一般情况下,就是 一些整型数等等。原子操作提供了一些比如加1减1,读取等的简单接口。
位操作
我理解位操作(读,写)都可以直接只由一条机器指令执行。所以就不存在race condition?如果是多核也不存在吗?
使用位操作,可以实现自旋锁,比如:
/* try to set lock */ while (test_and_set_bit(nr, addr) != 0) wait_for_a_while( ); /* do your work */ /* release lock, and check... */ if (test_and_clear_bit(nr, addr) = = 0) something_went_wrong( ); /* already released: error */
seqlocks
这个比较好玩。读的线程read不管三七二十一直接先读数据,读完后,看这个数 据在它读的时候是否被writer动过,如果动过,丢弃当前读的重新再读一次。看 起来像这样:
unsigned int seq; do { seq = read_seqbegin(&the_lock); /* Do what you need to do */ } while read_seqretry(&the_lock, seq);
其中,seq就是来查看是否数据被writer动过的。seqlock的read还有关中断的接 口。writer写的时候需要拿一个锁--自旋锁。所以自旋锁相关的限制对它也有。
seqlock一般锁的是简单,比较一致的数据。 它不能锁有指针的数据。 因为如果writer正在修改数据,谁也不知道reader读出来的指针是指向哪里的。
Read-Copy-Update
- 用在读很常见,写少见的情况。
- reader除了关闭抢断,啥也不管,读就是了,不过在读的操作,必须是原子 的。
- 数据全是通过指针访问。
- 申请新的空间,把旧的指针拷贝过来。
- 在新申请的空间中更新指针。这个时候,新的reader就直接访问新申请的空间的指针了。
- writer还需要等所有访问old数据的reader访问完成后,释放old数据。
参考资料
- Anatomy of Linux synchronization methods
- 维基百科: 自旋锁
- Linux 内核的排队自旋锁(FIFO Ticket Spinlock)
- TODO: Ticket spinlocks
如果你觉得本文不错,欢迎 donate