❶ 如何实现linux下多线程之间的互斥与同步
Linux设备驱动中必须解决的一个问题是多个进程对共享资源的并发访问,并发访问会导致竞态,linux提供了多种解决竞态问题的方式,这些方式适合不同的应用场景。
Linux内核是多进程、多线程的操作系统,它提供了相当完整的内核同步方法。内核同步方法列表如下:
中断屏蔽
原子操作
自旋锁
读写自旋锁
顺序锁
信号量
读写信号量
BKL(大内核锁)
Seq锁
一、并发与竞态:
定义:
并发(concurrency)指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态(race conditions)。
在linux中,主要的竞态发生在如下几种情况:
1、对称多处理器(SMP)多个CPU
特点是多个CPU使用共同的系统总线,因此可访问共同的外设和存储器。
2、单CPU内进程与抢占它的进程
3、中断(硬中断、软中断、Tasklet、底半部)与进程之间
只要并发的多个执行单元存在对共享资源的访问,竞态就有可能发生。
如果中断处理程序访问进程正在访问的资源,则竞态也会会发生。
多个中断之间本身也可能引起并发而导致竞态(中断被更高优先级的中断打断)。
解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问就是指一个执行单元在访问共享资源的时候,其他的执行单元都被禁止访问。
访问共享资源的代码区域被称为临界区,临界区需要以某种互斥机制加以保护,中断屏蔽,原子操作,自旋锁,和信号量都是linux设备驱动中可采用的互斥途径。
临界区和竞争条件:
所谓临界区(critical regions)就是访问和操作共享数据的代码段,为了避免在临界区中并发访问,编程者必须保证这些代码原子地执行——也就是说,代码在执行结束前不可被打断,就如同整个临界区是一个不可分割的指令一样,如果两个执行线程有可能处于同一个临界区中,那么就是程序包含一个bug,如果这种情况发生了,我们就称之为竞争条件(race conditions),避免并发和防止竞争条件被称为同步。
死锁:
死锁的产生需要一定条件:要有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有的资源都已经被占用了,所有线程都在相互等待,但它们永远不会释放已经占有的资源,于是任何线程都无法继续,这便意味着死锁的发生。
二、中断屏蔽
在单CPU范围内避免竞态的一种简单方法是在进入临界区之前屏蔽系统的中断。
由于linux内核的进程调度等操作都依赖中断来实现,内核抢占进程之间的并发也就得以避免了。
中断屏蔽的使用方法:
local_irq_disable()//屏蔽中断
//临界区
local_irq_enable()//开中断
特点:
由于linux系统的异步IO,进程调度等很多重要操作都依赖于中断,在屏蔽中断期间所有的中断都无法得到处理,因此长时间的屏蔽是很危险的,有可能造成数据丢失甚至系统崩溃,这就要求在屏蔽中断之后,当前的内核执行路径应当尽快地执行完临界区的代码。
中断屏蔽只能禁止本CPU内的中断,因此,并不能解决多CPU引发的竞态,所以单独使用中断屏蔽并不是一个值得推荐的避免竞态的方法,它一般和自旋锁配合使用。
三、原子操作
定义:原子操作指的是在执行过程中不会被别的代码路径所中断的操作。
(原子原本指的是不可分割的微粒,所以原子操作也就是不能够被分割的指令)
(它保证指令以“原子”的方式执行而不能被打断)
原子操作是不可分割的,在执行完毕不会被任何其它任务或事件中断。在单处理器系统(UniProcessor)中,能够在单条指令中完成的操作都可以认为是" 原子操作",因为中断只能发生于指令之间。这也是某些CPU指令系统中引入了test_and_set、test_and_clear等指令用于临界资源互斥的原因。但是,在对称多处理器(Symmetric Multi-Processor)结构中就不同了,由于系统中有多个处理器在独立地运行,即使能在单条指令中完成的操作也有可能受到干扰。我们以decl (递减指令)为例,这是一个典型的"读-改-写"过程,涉及两次内存访问。
通俗理解:
原子操作,顾名思义,就是说像原子一样不可再细分。一个操作是原子操作,意思就是说这个操作是以原子的方式被执行,要一口气执行完,执行过程不能够被OS的其他行为打断,是一个整体的过程,在其执行过程中,OS的其它行为是插不进来的。
分类:linux内核提供了一系列函数来实现内核中的原子操作,分为整型原子操作和位原子操作,共同点是:在任何情况下操作都是原子的,内核代码可以安全的调用它们而不被打断。
原子整数操作:
针对整数的原子操作只能对atomic_t类型的数据进行处理,在这里之所以引入了一个特殊的数据类型,而没有直接使用C语言的int型,主要是出于两个原因:
第一、让原子函数只接受atomic_t类型的操作数,可以确保原子操作只与这种特殊类型数据一起使用,同时,这也确保了该类型的数据不会被传递给其它任何非原子函数;
第二、使用atomic_t类型确保编译器不对相应的值进行访问优化——这点使得原子操作最终接收到正确的内存地址,而不是一个别名,最后就是在不同体系结构上实现原子操作的时候,使用atomic_t可以屏蔽其间的差异。
原子整数操作最常见的用途就是实现计数器。
另一点需要说明原子操作只能保证操作是原子的,要么完成,要么不完成,不会有操作一半的可能,但原子操作并不能保证操作的顺序性,即它不能保证两个操作是按某个顺序完成的。如果要保证原子操作的顺序性,请使用内存屏障指令。
atomic_t和ATOMIC_INIT(i)定义
typedef struct { volatile int counter; } atomic_t;
#define ATOMIC_INIT(i) { (i) }
在你编写代码的时候,能使用原子操作的时候,就尽量不要使用复杂的加锁机制,对多数体系结构来讲,原子操作与更复杂的同步方法相比较,给系统带来的开销小,对高速缓存行的影响也小,但是,对于那些有高性能要求的代码,对多种同步方法进行测试比较,不失为一种明智的作法。
原子位操作:
针对位这一级数据进行操作的函数,是对普通的内存地址进行操作的。它的参数是一个指针和一个位号。
为方便其间,内核还提供了一组与上述操作对应的非原子位函数,非原子位函数与原子位函数的操作完全相同,但是,前者不保证原子性,且其名字前缀多两个下划线。例如,与test_bit()对应的非原子形式是_test_bit(),如果你不需要原子性操作(比如,如果你已经用锁保护了自己的数据),那么这些非原子的位函数相比原子的位函数可能会执行得更快些。
四、自旋锁
自旋锁的引入:
如 果每个临界区都能像增加变量这样简单就好了,可惜现实不是这样,而是临界区可以跨越多个函数,例如:先得从一个数据结果中移出数据,对其进行格式转换和解 析,最后再把它加入到另一个数据结构中,整个执行过程必须是原子的,在数据被更新完毕之前,不能有其他代码读取这些数据,显然,简单的原子操作是无能为力 的(在单处理器系统(UniProcessor)中,能够在单条指令中完成的操作都可以认为是" 原子操作",因为中断只能发生于指令之间),这就需要使用更为复杂的同步方法——锁来提供保护。
自旋锁的介绍:
Linux内核中最常见的锁是自旋锁(spin lock),自旋锁最多只能被一个可执行线程持有,如果一个执行线程试图获得一个被争用(已经被持有)的自旋锁,那么该线程就会一直进行忙循环—旋转—等待锁重新可用,要是锁未被争用,请求锁的执行线程便能立刻得到它,继续执行,在任意时间,自旋锁都可以防止多于一个的执行线程同时进入理解区,注意同一个锁可以用在多个位置—例如,对于给定数据的所有访问都可以得到保护和同步。
一个被争用的自旋锁使得请求它的线程在等待锁重新可用时自旋(特别浪费处理器时间),所以自旋锁不应该被长时间持有,事实上,这点正是使用自旋锁的初衷,在短期间内进行轻量级加锁,还可以采取另外的方式来处理对锁的争用:让请求线程睡眠,直到锁重新可用时再唤醒它,这样处理器就不必循环等待,可以去执行其他代码,这也会带来一定的开销——这里有两次明显的上下文切换, 被阻塞的线程要换出和换入。因此,持有自旋锁的时间最好小于完成两次上下文切换的耗时,当然我们大多数人不会无聊到去测量上下文切换的耗时,所以我们让持 有自旋锁的时间应尽可能的短就可以了,信号量可以提供上述第二种机制,它使得在发生争用时,等待的线程能投入睡眠,而不是旋转。
自旋锁可以使用在中断处理程序中(此处不能使用信号量,因为它们会导致睡眠),在中断处理程序中使用自旋锁时,一定要在获取锁之前,首先禁止本地中断(在 当前处理器上的中断请求),否则,中断处理程序就会打断正持有锁的内核代码,有可能会试图去争用这个已经持有的自旋锁,这样以来,中断处理程序就会自旋, 等待该锁重新可用,但是锁的持有者在这个中断处理程序执行完毕前不可能运行,这正是我们在前一章节中提到的双重请求死锁,注意,需要关闭的只是当前处理器上的中断,如果中断发生在不同的处理器上,即使中断处理程序在同一锁上自旋,也不会妨碍锁的持有者(在不同处理器上)最终释放锁。
自旋锁的简单理解:
理解自旋锁最简单的方法是把它作为一个变量看待,该变量把一个临界区或者标记为“我当前正在运行,请稍等一会”或者标记为“我当前不在运行,可以被使用”。如果A执行单元首先进入例程,它将持有自旋锁,当B执行单元试图进入同一个例程时,将获知自旋锁已被持有,需等到A执行单元释放后才能进入。
自旋锁的API函数:
其实介绍的几种信号量和互斥机制,其底层源码都是使用自旋锁,可以理解为自旋锁的再包装。所以从这里就可以理解为什么自旋锁通常可以提供比信号量更高的性能。
自旋锁是一个互斥设备,他只能会两个值:“锁定”和“解锁”。它通常实现为某个整数之中的单个位。
“测试并设置”的操作必须以原子方式完成。
任何时候,只要内核代码拥有自旋锁,在相关CPU上的抢占就会被禁止。
适用于自旋锁的核心规则:
(1)任何拥有自旋锁的代码都必须使原子的,除服务中断外(某些情况下也不能放弃CPU,如中断服务也要获得自旋锁。为了避免这种锁陷阱,需要在拥有自旋锁时禁止中断),不能放弃CPU(如休眠,休眠可发生在许多无法预期的地方)。否则CPU将有可能永远自旋下去(死机)。
(2)拥有自旋锁的时间越短越好。
需 要强调的是,自旋锁别设计用于多处理器的同步机制,对于单处理器(对于单处理器并且不可抢占的内核来说,自旋锁什么也不作),内核在编译时不会引入自旋锁 机制,对于可抢占的内核,它仅仅被用于设置内核的抢占机制是否开启的一个开关,也就是说加锁和解锁实际变成了禁止或开启内核抢占功能。如果内核不支持抢 占,那么自旋锁根本就不会编译到内核中。
内核中使用spinlock_t类型来表示自旋锁,它定义在:
typedef struct {
raw_spinlock_t raw_lock;
#if defined(CONFIG_PREEMPT) && defined(CONFIG_SMP)
unsigned int break_lock;
#endif
} spinlock_t;
对于不支持SMP的内核来说,struct raw_spinlock_t什么也没有,是一个空结构。对于支持多处理器的内核来说,struct raw_spinlock_t定义为
typedef struct {
unsigned int slock;
} raw_spinlock_t;
slock表示了自旋锁的状态,“1”表示自旋锁处于解锁状态(UNLOCK),“0”表示自旋锁处于上锁状态(LOCKED)。
break_lock表示当前是否由进程在等待自旋锁,显然,它只有在支持抢占的SMP内核上才起作用。
自旋锁的实现是一个复杂的过程,说它复杂不是因为需要多少代码或逻辑来实现它,其实它的实现代码很少。自旋锁的实现跟体系结构关系密切,核心代码基本也是由汇编语言写成,与体协结构相关的核心代码都放在相关的目录下,比如。对于我们驱动程序开发人员来说,我们没有必要了解这么spinlock的内部细节,如果你对它感兴趣,请参考阅读Linux内核源代码。对于我们驱动的spinlock接口,我们只需包括头文件。在我们详细的介绍spinlock的API之前,我们先来看看自旋锁的一个基本使用格式:
#include
spinlock_t lock = SPIN_LOCK_UNLOCKED;
spin_lock(&lock);
....
spin_unlock(&lock);
从使用上来说,spinlock的API还很简单的,一般我们会用的的API如下表,其实它们都是定义在中的宏接口,真正的实现在中
#include
SPIN_LOCK_UNLOCKED
DEFINE_SPINLOCK
spin_lock_init( spinlock_t *)
spin_lock(spinlock_t *)
spin_unlock(spinlock_t *)
spin_lock_irq(spinlock_t *)
spin_unlock_irq(spinlock_t *)
spin_lock_irqsace(spinlock_t *,unsigned long flags)
spin_unlock_irqsace(spinlock_t *, unsigned long flags)
spin_trylock(spinlock_t *)
spin_is_locked(spinlock_t *)
• 初始化
spinlock有两种初始化形式,一种是静态初始化,一种是动态初始化。对于静态的spinlock对象,我们用 SPIN_LOCK_UNLOCKED来初始化,它是一个宏。当然,我们也可以把声明spinlock和初始化它放在一起做,这就是 DEFINE_SPINLOCK宏的工作,因此,下面的两行代码是等价的。
DEFINE_SPINLOCK (lock);
spinlock_t lock = SPIN_LOCK_UNLOCKED;
spin_lock_init 函数一般用来初始化动态创建的spinlock_t对象,它的参数是一个指向spinlock_t对象的指针。当然,它也可以初始化一个静态的没有初始化的spinlock_t对象。
spinlock_t *lock
......
spin_lock_init(lock);
• 获取锁
内核提供了三个函数用于获取一个自旋锁。
spin_lock:获取指定的自旋锁。
spin_lock_irq:禁止本地中断并获取自旋锁。
spin_lock_irqsace:保存本地中断状态,禁止本地中断并获取自旋锁,返回本地中断状态。
自旋锁是可以使用在中断处理程序中的,这时需要使用具有关闭本地中断功能的函数,我们推荐使用 spin_lock_irqsave,因为它会保存加锁前的中断标志,这样就会正确恢复解锁时的中断标志。如果spin_lock_irq在加锁时中断是关闭的,那么在解锁时就会错误的开启中断。
另外两个同自旋锁获取相关的函数是:
spin_trylock():尝试获取自旋锁,如果获取失败则立即返回非0值,否则返回0。
spin_is_locked():判断指定的自旋锁是否已经被获取了。如果是则返回非0,否则,返回0。
• 释放锁
同获取锁相对应,内核提供了三个相对的函数来释放自旋锁。
spin_unlock:释放指定的自旋锁。
spin_unlock_irq:释放自旋锁并激活本地中断。
spin_unlock_irqsave:释放自旋锁,并恢复保存的本地中断状态。
五、读写自旋锁
如 果临界区保护的数据是可读可写的,那么只要没有写操作,对于读是可以支持并发操作的。对于这种只要求写操作是互斥的需求,如果还是使用自旋锁显然是无法满 足这个要求(对于读操作实在是太浪费了)。为此内核提供了另一种锁-读写自旋锁,读自旋锁也叫共享自旋锁,写自旋锁也叫排他自旋锁。
读写自旋锁是一种比自旋锁粒度更小的锁机制,它保留了“自旋”的概念,但是在写操作方面,只能最多有一个写进程,在读操作方面,同时可以有多个读执行单元,当然,读和写也不能同时进行。
读写自旋锁的使用也普通自旋锁的使用很类似,首先要初始化读写自旋锁对象:
// 静态初始化
rwlock_t rwlock = RW_LOCK_UNLOCKED;
//动态初始化
rwlock_t *rwlock;
...
rw_lock_init(rwlock);
在读操作代码里对共享数据获取读自旋锁:
read_lock(&rwlock);
...
read_unlock(&rwlock);
在写操作代码里为共享数据获取写自旋锁:
write_lock(&rwlock);
...
write_unlock(&rwlock);
需要注意的是,如果有大量的写操作,会使写操作自旋在写自旋锁上而处于写饥饿状态(等待读自旋锁的全部释放),因为读自旋锁会自由的获取读自旋锁。
读写自旋锁的函数类似于普通自旋锁,这里就不一一介绍了,我们把它列在下面的表中。
RW_LOCK_UNLOCKED
rw_lock_init(rwlock_t *)
read_lock(rwlock_t *)
read_unlock(rwlock_t *)
read_lock_irq(rwlock_t *)
read_unlock_irq(rwlock_t *)
read_lock_irqsave(rwlock_t *, unsigned long)
read_unlock_irqsave(rwlock_t *, unsigned long)
write_lock(rwlock_t *)
write_unlock(rwlock_t *)
write_lock_irq(rwlock_t *)
write_unlock_irq(rwlock_t *)
write_lock_irqsave(rwlock_t *, unsigned long)
write_unlock_irqsave(rwlock_t *, unsigned long)
rw_is_locked(rwlock_t *)
六、顺序琐
顺序琐(seqlock)是对读写锁的一种优化,若使用顺序琐,读执行单元绝不会被写执行单元阻塞,也就是说,读执行单元可以在写执行单元对被顺序琐保护的共享资源进行写操作时仍然可以继续读,而不必等待写执行单元完成写操作,写执行单元也不需要等待所有读执行单元完成读操作才去进行写操作。
但是,写执行单元与写执行单元之间仍然是互斥的,即如果有写执行单元在进行写操作,其它写执行单元必须自旋在哪里,直到写执行单元释放了顺序琐。
如果读执行单元在读操作期间,写执行单元已经发生了写操作,那么,读执行单元必须重新读取数据,以便确保得到的数据是完整的,这种锁在读写同时进行的概率比较小时,性能是非常好的,而且它允许读写同时进行,因而更大的提高了并发性,
注意,顺序琐由一个限制,就是它必须被保护的共享资源不含有指针,因为写执行单元可能使得指针失效,但读执行单元如果正要访问该指针,将导致Oops。
七、信号量
Linux中的信号量是一种睡眠锁,如果有一个任务试图获得一个已经被占用的信号量时,信号量会将其推进一个等待队列,然后让其睡眠,这时处理器能重获自由,从而去执行其它代码,当持有信号量的进程将信号量释放后,处于等待队列中的哪个任务被唤醒,并获得该信号量。
信号量,或旗标,就是我们在操作系统里学习的经典的P/V原语操作。
P:如果信号量值大于0,则递减信号量的值,程序继续执行,否则,睡眠等待信号量大于0。
V:递增信号量的值,如果递增的信号量的值大于0,则唤醒等待的进程。
信号量的值确定了同时可以有多少个进程可以同时进入临界区,如果信号量的初始值始1,这信号量就是互斥信号量(MUTEX)。对于大于1的非0值信号量,也可称为计数信号量(counting semaphore)。对于一般的驱动程序使用的信号量都是互斥信号量。
类似于自旋锁,信号量的实现也与体系结构密切相关,具体的实现定义在头文件中,对于x86_32系统来说,它的定义如下:
struct semaphore {
atomic_t count;
int sleepers;
wait_queue_head_t wait;
};
信号量的初始值count是atomic_t类型的,这是一个原子操作类型,它也是一个内核同步技术,可见信号量是基于原子操作的。我们会在后面原子操作部分对原子操作做详细介绍。
信号量的使用类似于自旋锁,包括创建、获取和释放。我们还是来先展示信号量的基本使用形式:
static DECLARE_MUTEX(my_sem);
......
if (down_interruptible(&my_sem))
{
return -ERESTARTSYS;
}
......
up(&my_sem)
Linux内核中的信号量函数接口如下:
static DECLARE_SEMAPHORE_GENERIC(name, count);
static DECLARE_MUTEX(name);
seam_init(struct semaphore *, int);
init_MUTEX(struct semaphore *);
init_MUTEX_LOCKED(struct semaphore *)
down_interruptible(struct semaphore *);
down(struct semaphore *)
down_trylock(struct semaphore *)
up(struct semaphore *)
• 初始化信号量
信号量的初始化包括静态初始化和动态初始化。静态初始化用于静态的声明并初始化信号量。
static DECLARE_SEMAPHORE_GENERIC(name, count);
static DECLARE_MUTEX(name);
对于动态声明或创建的信号量,可以使用如下函数进行初始化:
seam_init(sem, count);
init_MUTEX(sem);
init_MUTEX_LOCKED(struct semaphore *)
显然,带有MUTEX的函数始初始化互斥信号量。LOCKED则初始化信号量为锁状态。
• 使用信号量
信号量初始化完成后我们就可以使用它了
down_interruptible(struct semaphore *);
down(struct semaphore *)
down_trylock(struct semaphore *)
up(struct semaphore *)
down函数会尝试获取指定的信号量,如果信号量已经被使用了,则进程进入不可中断的睡眠状态。down_interruptible则会使进程进入可中断的睡眠状态。关于进程状态的详细细节,我们在内核的进程管理里在做详细介绍。
down_trylock尝试获取信号量, 如果获取成功则返回0,失败则会立即返回非0。
当退出临界区时使用up函数释放信号量,如果信号量上的睡眠队列不为空,则唤醒其中一个等待进程。
八、读写信号量
类似于自旋锁,信号量也有读写信号量。读写信号量API定义在头文件中,它的定义其实也是体系结构相关的,因此具体实现定义在头文件中,以下是x86的例子:
struct rw_semaphore {
signed long count;
spinlock_t wait_lock;
struct list_head wait_list;
};
❷ 楂樻ц兘寮傛io鏈哄埗锛歩o_uring
楂樻ц兘寮傛I/O澶勭悊鏈哄埗锛歩o_uring鐨勯潻鍛芥х獊鐮闅忕潃Linux 5.10鐗堟湰鐨勯潻鏂帮紝io_uring寮傛IO鎺ュ彛搴旇繍鑰岀敓锛屽畠閫氳繃宸у欑殑鐢ㄦ埛绌洪棿鍐呭瓨鏄犲皠鍜屾棤閿佺幆褰㈤槦鍒楄捐★紝鏋佸ぇ鍦版彁鍗囦簡鏁版嵁澶勭悊鐨勬晥鐜囥俰o_uring浠ュ叾鐙鐗圭殑璁捐★紝灏嗕换鍔℃彁浜や笌缁撴灉杩斿洖鏃犵紳鏁村悎锛屽噺灏戜簡鍐呭瓨鎷疯礉鐨勫紑閿锛屽睍鐜板嚭鍗撹秺鐨勬ц兘銆
io_uring鐨勬牳蹇冩満鍒跺湪浜庡叾鍙屽悜闃熷垪缁撴瀯锛屽寘鎷琒Q锛圫ubmit Queue锛夊拰CQ锛圕ompletion Queue锛夈係Q璐熻矗鎺ユ敹鐢ㄦ埛鐨処O璇锋眰锛岃孋Q鍒欒礋璐i氱煡鐢ㄦ埛璇锋眰鐨勫畬鎴愮姸鎬併傚畠浠閫氳繃鍐呭瓨灞忛殰鎿嶄綔淇濇寔鍚屾ワ紝鏃犻渶閿佹満鍒讹紝浠庤岄伩鍏嶄簡甯歌佺殑绔炴佹潯浠躲
绯荤粺璋冪敤鐨勫叧閿鐜鑺
涓轰簡鐩磋傚湴灞曠ずio_uring鐨勫▉鍔涳紝鎴戜滑閫氳繃瀹炴垬婕旂ず锛氫緥濡傦紝uring_cat绋嬪簭锛屽畠鍒╃敤io_uring灏佽呬簡鏂囦欢鎿嶄綔锛屽疄鐜板湪纾佺洏I/O鏂归潰鐨勬樉钁楁ц兘鎻愬崌銆傚湪瀹為檯娴嬭瘯涓锛屽规瘮寮傛ユā寮忥紙io_uring锛岃揪鍒版儕浜虹殑19.0k IOPS锛夊拰鍚屾ユā寮忥紙8k IOPS锛夛紝io_uring鍦ㄧ佺洏I/O鎬ц兘涓婂崰鎹浜嗘樉钁椾紭鍔裤
姝ゅ栵紝rust_echo_bench鏈嶅姟鍣ㄦ祴璇曡繘涓姝ヨ瘉瀹炰簡io_uring鐨勪紭瓒婃э紝瀹冨湪澶勭悊澶ч噺骞跺彂璇锋眰鏃讹紝灞曠幇鍑哄崜瓒婄殑鍚炲悙閲忓拰鍝嶅簲閫熷害銆傛繁鍏ョ悊瑙io_uring鐨勬牳蹇冪郴缁熻皟鐢ㄦ帴鍙o紝鑳藉熷府鍔╁紑鍙戣呮洿鏈夋晥鍦板埄鐢ㄨ繖涓寮哄ぇ鐨勫伐鍏枫
瀹炶返搴旂敤涓庡︿範璧勬簮
瑕佹繁鍏ユ帉鎻io_uring锛孌PDK鏁欑▼鏄涓涓鏋佸ソ鐨勫︿範璧风偣銆傞氳繃瀹冿紝寮鍙戣呭彲浠ヤ簡瑙e備綍鍒╃敤io_uring杩涜岄珮鏁堢殑缃戠粶鍜孖/O鎿嶄綔锛屼互鍙婂備綍浼樺寲绯荤粺鎬ц兘銆傜珛鍗宠㈤槄锛岃笍涓奿o_uring鐨勯珮鎬ц兘涔嬫梾锛
灏界io_uring鎻愪緵浜嗗己澶х殑鎬ц兘鎻愬崌锛屼絾鍏朵娇鐢ㄤ篃瑕佹眰寮鍙戣呭叿澶囦竴瀹氱殑绯荤粺缂栫▼鐭ヨ瘑鍜屽唴瀛樼$悊鎶宸с傞氳繃缁嗚嚧鐨勪唬鐮佺ず渚嬪拰瀹炶返锛屼綘灏嗚兘澶熼嗙暐io_uring鐨勫唴鍦ㄩ瓍鍔涳紝涓哄簲鐢ㄧ▼搴忓甫鏉ュ墠鎵鏈鏈夌殑閫熷害涓庢晥鐜囥
❸ 关于 linux 驱动中并发控制的方法有哪些
需要一定的努力才可以学好:
Linux设备驱动是linux内核的一部分,是用来屏蔽硬件细节,为上层提供标准接口的一种技术手段。为了能够编写出质量比较高的驱动程序,要求工程师必须具备以下几个方面的知识:
1、 熟悉处理器的性能
如:处理器的体系结构、汇编语言、工作模式、异常处理等。对于初学者来说,在还不熟悉驱动编写方法的情况下,可以先不把重心放在这一项上,因为可能因为它的枯燥、抽象而影响到你对设备驱动的兴趣。随着你不断地熟悉驱动的编写,你会很自然的意识到此项的重要性。
2、掌握驱动目标的硬件工作原理及通讯协议
如:串口控制器、显卡控制器、硬件编解码、存储卡控制器、I2C通讯、SPI通讯、USB通讯、SDIO通讯、I2S通讯、PCI通讯等。编写设备驱动的前提就是需要了解设备的操作方法,所以这些内容的重要程度不言而喻。但不是说要把所有设备的操作方法都熟悉了以后才可以写驱动,你只需要了解你要驱动的硬件就可以了。
一、掌握硬件的控制方法
如:中断、轮询、DMA 等,通常一个硬件控制器会有多种控制方法,你需要根据系统性能的需要合理的选择操作方法。初学阶段以实现功能为目的,掌握的顺序应该是,轮询->中断->DMA。随着学习的深入,需要综合考虑系统的性能需求,采取合适的方法。
二、良好的GNU C语言编程基础
如:C语言的指针、结构体、内存操作、链表、队列、栈、C和汇编混合编程等。这些编程语法是编写设备驱动的基础,无论对于初学者还是有经验者都非常重要。
三、 良好的linux操作系统概念
如:多进程、多线程、进程调度、进程抢占、进程上下文、虚拟内存、原子操作、阻塞、睡眠、同步等概念及它们之间的关系。这些概念及方法在设备驱动里的使用是linux设备驱动区别单片机编程的最大特点,只有理解了它们才会编写出高质量的驱动。
四、掌握linux内核中设备驱动的编写接口
如:字符设备的cdev、块设备的gendisk、网络设备的net_device,以及基于这些基本接口的framebuffer设备的fb_info、mtd设备的mtd_info、tty设备的tty_driver、usb设备的usb_driver、mmc设备的mmc_host等。
❹ linux骞跺彂绋嬪簭璁捐 瀹炶灏忕粨锛屾ユ眰锛侊紒锛侊紒瀛楁暟500~600
瀹為獙6 Linux杩涚▼骞跺彂绋嬪簭璁捐
1 瀹為獙鐩鐨勶細
鎺屾彙Linux鐜澧冧笅鐨勮繘绋嬪苟鍙戠▼搴忓強绠¢亾搴旂敤绋嬪簭鐨勭紪鍐欒佺偣銆
2 瀹為獙鍐呭瑰拰瀹為獙姝ラわ細
锛1锛 璋冭瘯骞惰繍琛3.10鐨勫苟鍙戠▼搴忚捐″疄渚嬶紝鏄剧ず缁撴灉鏄浠涔堬紝骞跺垎鏋愪箣銆
閫氳繃pipeline.c杩欎釜鏂囦欢锛岃皟鐢╟hild1.c鍜宖ather1.c杩欎袱涓绋嬪簭锛宖ather1.c鍐欏叆绠$悊锛岀劧鍚庡啀閫氳繃child1.c璇荤¢亾锛屽洜姝よ緭鍑轰负浠ヤ笂缁撴灉銆
锛2锛 缂栧啓涓涓骞跺彂绋嬪簭锛岀埗杩涚▼鎵撳嵃鈥淭he Parent is running鈥濓紱瀛愯繘绋嬫墦鍗扳淭he Child is running鈥濓紱
#include<stdio.h>
#include<unistd.h>
main()
{
int p1;
while((p1=fork())==-1);
if(p1>0)
{
wait(0);
printf("The Parent is running.\n");
}
else
{
printf("The Child is running.\n");
exit(0);
}
}
缁撴灉涓猴細
The Child is running.
The Parent is running.
锛3锛 缂栧啓涓涓绠¢亾搴旂敤绋嬪簭锛岀埗杩涚▼閫氳繃绠¢亾鎻愪緵瀛楃︿覆鈥減ut the string into the pipe.鈥濈粰瀛愯繘绋嬶紝瀛愯繘绋嬮氳繃绠¢亾鎺ユ敹杩欐潯淇℃伅锛岀劧鍚庢墦鍗拌緭鍑恒
#include<stdio.h>
#include<unistd.h>
main()
{
int p1,fd[2];
char outpipe[50]; //瀹氫箟璇荤紦鍐插尯
char inpipe[50]="put the string into the pipe."; //瀹氫箟鍐欑紦鍐插尯
pipe(fd); //鍒涘缓鏃犲悕绠¢亾fd
while((p1=fork())==-1);
if (p1>0) //鐖惰繘绋嬭繑鍥
{
write(fd[1],inpipe,50); //鍐欎俊鎭鍒扮¢亾
wait(0);
}
else //瀛愯繘绋嬭繑鍥
{
read(fd[0],outpipe,50); //浠庣¢亾璇讳俊鎭鍒拌荤紦鍐插尯
printf("%s\n",outpipe); //鏄剧ず璇诲埌鐨勪俊鎭
exit(0);
}
}
缁撴灉涓猴細
put the string into the pipe.
3锛庡疄楠岃佹眰锛氬啓鍑哄疄楠屾姤鍛婂苟灏嗙粨鏋滀笂浼犲埌FTP SERVER涓婅嚜宸辩殑浣滀笟鐩褰曘
❺ 高性能网络服务器编程:为什么linux下epoll
基本的IO编程过程(包括网络IO和文件IO)是,打开文件描述符(windows是handler,java是stream或channel),多路捕获(Multiplexe,即select和poll和epoll)IO可读写的状态,而后可以读写的文件描述符进行IO读写,由于IO设备速度和CPU内存比速度会慢,为了更好的利用CPU和内存,会开多线程,每个线程读写一个文件描述符。
但C10K问题,让我们意识到在超大数量的网络连接下,机器设备和网络速度不再是瓶颈,瓶颈在于操作系统和IO应用程序的沟通协作的方式。
举个例子,一万个socket连接过来,传统的IO编程模型要开万个线程来应对,还要注意,socket会关闭打开,一万个线程要不断的关闭线程重建线程,资源都浪费在这上面了,我们算建立一个线程耗1M内存,1万个线程机器至少要10G内存,这在IA-32的机器架构下基本是不可能的(要开PAE),现在x64架构才有可能舒服点,要知道,这仅仅是粗略算的内存消耗。别的资源呢?
所以,高性能的网络编程(即IO编程),第一,需要松绑IO连接和应用程序线程的对应关系,这就是非阻塞(nonblocking)、异步(asynchronous)的要求的由来(构造一个线程池,epoll监控到有数的fd,把fd传入线程池,由这些worker thread来读写io)。第二,需要高性能的OS对IO设备可读写(数据来了)的通知方式:从level-triggered notification到edge-triggered notification,关于这个通知方式,我们稍后谈。
需要注意异步,不等于AIO(asynchronous IO),Linux的AIO和java的AIO都是实现异步的一种方式,都是渣,这个我们也接下来会谈到。
针对前面说的这两点,我们看看select和poll的问题
这两个函数都在每次调用的时候要求我们把需要监控(看看有没有数据)的文件描述符,通过数组传递进入内核,内核每次都要扫描这些文件描述符,去理解它们,建立一个文件描述符和IO对应的数组(实际内核工作会有好点的实现方式,但可以这么理解先),以便IO来的时候,通知这些文件描述符,进而通知到进程里等待的这些select、poll。当有一万个文件描述符要监控的时候呢(一万个网络连接)?这个工作效率是很低的,资源要求却很高。
我们看epoll
epoll很巧妙,分为三个函数,第一个函数创建一个session类似的东西,第二函数告诉内核维持这个session,并把属于session内的fd传给内核,第三个函数epoll_wait是真正的监控多个文件描述符函数,只需要告诉内核,我在等待哪个session,而session内的fd,内核早就分析过了,不再在每次epoll调用的时候分析,这就节省了内核大部分工作。这样每次调用epoll,内核不再重新扫描fd数组,因为我们维持了session。
说道这里,只有一个字,开源,赞,众人拾柴火焰高,赞。
epoll的效率还不仅仅体现在这里,在内核通知方式上,也改进了,我们先看select和poll的通知方式,也就是level-triggered notification,内核在被DMA中断,捕获到IO设备来数据后,本来只需要查找这个数据属于哪个文件描述符,进而通知线程里等待的函数即可,但是,select和poll要求内核在通知阶段还要继续再扫描一次刚才所建立的内核fd和io对应的那个数组,因为应用程序可能没有真正去读上次通知有数据后的那些fd,应用程序上次没读,内核在这次select和poll调用的时候就得继续通知,这个os和应用程序的沟通方式效率是低下的。只是方便编程而已(可以不去读那个网络io,方正下次会继续通知)。
于是epoll设计了另外一种通知方式:edge-triggered notification,在这个模式下,io设备来了数据,就只通知这些io设备对应的fd,上次通知过的fd不再通知,内核不再扫描一大堆fd了。
基于以上分析,我们可以看到epoll是专门针对大网络并发连接下的os和应用沟通协作上的一个设计,在linux下编网络服务器,必然要采用这个,nginx、PHP的国产异步框架swool、varnish,都是采用这个。
注意还要打开epoll的edge-triggered notification。而java的NIO和NIO.2都只是用了epoll,没有打开edge-triggered notification,所以不如JBoss的Netty。
接下来我们谈谈AIO的问题,AIO希望的是,你select,poll,epoll都需要用一个函数去监控一大堆fd,那么我AIO不需要了,你把fd告诉内核,你应用程序无需等待,内核会通过信号等软中断告诉应用程序,数据来了,你直接读了,所以,用了AIO可以废弃select,poll,epoll。
但linux的AIO的实现方式是内核和应用共享一片内存区域,应用通过检测这个内存区域(避免调用nonblocking的read、write函数来测试是否来数据,因为即便调用nonblocking的read和write由于进程要切换用户态和内核态,仍旧效率不高)来得知fd是否有数据,可是检测内存区域毕竟不是实时的,你需要在线程里构造一个监控内存的循环,设置sleep,总的效率不如epoll这样的实时通知。所以,AIO是渣,适合低并发的IO操作。所以java7引入的NIO.2引入的AIO对高并发的网络IO设计程序来说,也是渣,只有Netty的epoll+edge-triggered notification最牛,能在linux让应用和OS取得最高效率的沟通。