操作系统真相还原 第十章 输入输出系统
第十章 输入输出系统
同步机制-锁
找出代码中的临界区、互斥、竞争条件
临界区:多个任务访问同一资源时,每个任务中访问资源的指令代码被称为临界区,临界区是代码,并不是资源。
互斥:也可称为排他,是指某一时刻公共资源只能被一个任务独享。
竞争条件:指多个任务以非互斥的方式同时进入临界区,大家对公共资源的访问是以竞争的方式并行进行的。
多线程访问公共资源时出问题的原因是产生了竞争条件,也就是多个任务同时出现在自己的临界区。为避免产生竞争条件,必须保证任意时刻只能有1个任务处于临界区。因此,只要保证各线程自己临界区中的所有代码都是原子操作,即临界区中的指令要么一条不做,要么 气呵成全部执行完,执行期间绝对不能被换下处理器。
其实,之所以出现竞争条件,归根结底是因为临界区中的指令太多了,如果临界区仅有一条指令的话,这本身已属于原子操作,完全不需要互斥。因此,在临界区中指令多于一条时才需要互斥。当然,临界区中很少存在只有一条指令的情况,因此我们必须提供一种互斥的机制,互斥能使临界区具有原子性,避免产生竞争条件,从而避免了多任务访问公共资源时出问题。
信号量
互斥可以使用硬件机制关中断的方式实现,但是如果临界区范围太大,使用关中断方式会影响并发。可使用锁的方式来实现互斥,不过锁中可以也要借助关中断来做原子操作,不过这个关中断的范围较小。
信号量:
用于实现锁。在计算机中,信号量就是个0以上的整数值,当为0时表示己无可用信号 ,或者说条件不再允许,因它表示某种信号的累积“量“,故称为信号量。信号量就是个计数器,它的计数值是自然数,用来记录所积累信号的数量。信号是个泛指,取决于信号量的实际应用环境。信号的意义取决于您用信号来做什么,信号量仅仅是一种程序设计构造方法。
两个操作:
增加操作 up:
- 将信号量的值加1。
- 唤醒在此信号量上等待的线程。
减少操作 down:
- 判断信号量是否大于0。
- 若信号量大于0,则将信号减1。
- 若信号量等于0,当前线程将自己阻塞,在此信号量上等待。
信号量是个全局共享变量,up和down又都是读写这个全局变量的操作,而且它们都包含一系列的子操作,因此它们必须都是原子操作。
如果信号量取值只为0和1,便称为二元信号量,我们可以利用二元信号量来实现锁。
二元信号量中, down 操作就是获得锁, up 操作就是释放锁 我们可以让线程通过锁进入临界区,可以借此保证只有一个线程可以进入临界区,从而做到互斥,大致流程为:
• 线程A进入临界区前先通过 down 操作获得锁,此时信号量的值便为0。
• 后续线程B再进入临界区时也通过 down 操作获得锁,由于信号量为0,线程B便在此信号量上等待,也就是相当于线程B进入了睡眠态。
• 当线程A从临界区出来后执行 up 操作释放锁,此时信号量的值重新变成1 ,之后线程A将线程B唤醒。
• 线程B醒来后获得了锁,进入临界区。
线程的阻塞与唤醒
阻塞
阻塞:线程运行的原理是调度器从就绪队列中取出线程上cpu,如果不把线程放入就绪队列,就可以实现阻塞。(阻塞并不一定跟锁有关,阻塞并不一定跟锁有关,阻塞并不一定跟锁有关)
阻塞是本线程自己发出的动作,自己阻塞自己,阻塞是主动的。而已阻塞的线程需要别人来唤醒,唤醒是被动的。
thread_block,当前线程将自己阻塞,调用schedule()函数将当前线程换下处理器,schedule函数将状态为TASK_RUNNING的线程再次加入到就绪队列,状态为阻塞状态的线程不会加入到就绪队列。
/* 当前线程将自己阻塞,标志其状态为stat. */
void thread_block(enum task_status stat) {
/* stat取值为TASK_BLOCKED,TASK_WAITING,TASK_HANGING,也就是只有这三种状态才不会被调度*/
ASSERT(((stat == TASK_BLOCKED) || (stat == TASK_WAITING) || (stat == TASK_HANGING)));
enum intr_status old_status = intr_disable();
struct task_struct* cur_thread = running_thread();
cur_thread->status = stat; // 置其状态为stat
schedule(); // 将当前线程换下处理器
/* 待当前线程被解除阻塞后才继续运行下面的intr_set_status */
intr_set_status(old_status);
}
/* 实现任务调度 */
void schedule() {
ASSERT(intr_get_status() == INTR_OFF);
struct task_struct* cur = running_thread();
if (cur->status == TASK_RUNNING) { // 若此线程只是cpu时间片到了,将其加入到就绪队列尾
ASSERT(!elem_find(&thread_ready_list, &cur->general_tag));
list_append(&thread_ready_list, &cur->general_tag);
cur->ticks = cur->priority; // 重新将当前线程的ticks再重置为其priority;
cur->status = TASK_READY;
} else {
/* 若此线程需要某事件发生后才能继续上cpu运行,
不需要将其加入队列,因为当前线程不在就绪队列中。*/
}
ASSERT(!list_empty(&thread_ready_list));
thread_tag = NULL; // thread_tag清空
/* 将thread_ready_list队列中的第一个就绪线程弹出,准备将其调度上cpu. */
thread_tag = list_pop(&thread_ready_list);
struct task_struct* next = elem2entry(struct task_struct, general_tag, thread_tag);
next->status = TASK_RUNNING;
switch_to(cur, next);
}
需要借助关中断实现原子操作,关了中断就不响应时钟中断了,也就没有线程切换了,相当于单线程执行。
/* 关中断,并且返回关中断前的状态 */
enum intr_status intr_disable() {
enum intr_status old_status;
if (INTR_ON == intr_get_status()) {
old_status = INTR_ON;
asm volatile("cli" : : : "memory"); // 关中断,cli指令将IF位置0
return old_status;
} else {
old_status = INTR_OFF;
return old_status;
}
}
/* 将中断状态设置为status */
enum intr_status intr_set_status(enum intr_status status) {
return status & INTR_ON ? intr_enable() : intr_disable();
}
唤醒
唤醒是被动的。
thread_unblock,它将某线程解除阻塞,也就是唤醒某线程。被阻塞的线程己无法运行,无法自己唤醒自己,必须被其他线程唤醒,因此参数 pthread 指向的是目前已经被阻塞,又希望被唤醒的线程。函数 thread unblock 是由当前运行的线程调用的,由它实施唤醒动作,它是被阻塞线程 pthread 的“救世主”。list_push,将被阻塞线程放到就绪队列中,放在队列的头部,使其尽快得到调度,状态改为TASK_READY。
/* 将线程pthread解除阻塞 */
void thread_unblock(struct task_struct* pthread) {
enum intr_status old_status = intr_disable();
ASSERT(((pthread->status == TASK_BLOCKED) || (pthread->status == TASK_WAITING) || (pthread->status == TASK_HANGING)));
if (pthread->status != TASK_READY) {
ASSERT(!elem_find(&thread_ready_list, &pthread->general_tag));
if (elem_find(&thread_ready_list, &pthread->general_tag)) {
PANIC("thread_unblock: blocked thread in ready_list\n");
}
list_push(&thread_ready_list, &pthread->general_tag); // 放到队列的最前面,使其尽快得到调度
pthread->status = TASK_READY;
}
intr_set_status(old_status);
}
锁的实现
信号量的实现
信号量结构
/* 信号量结构体 */
struct semaphore {
uint8_t value;
struct list waiters;
};
初始化
/* 初始化信号量 */
void sema_init(struct semaphore* psema, uint8_t value) {
psema->value = value; // 为信号量赋初值
list_init(&psema->waiters); //初始化信号量的等待队列
}
信号量down操作
- 判断信号量是否大于0。
- 若信号量大于0,则将信号减1。
- 若信号量等于0,当前线程将自己阻塞,在此信号量上等待。
判断信号量是否为0,应使用while而非if。
信号量为0,thread_block阻塞当前线程,被唤醒后从thread_block下继续执行。唤醒后也有可能有新的线程创建竞争信号量并且先一步获取到信号量(从调用sema_down函数到函数执行第一条语句关中断前,也可能会有其他线程拿到信号量),如果使用if会出现对已经为0的信号量减一,使用while可以再次判断信号量。
/* 信号量down操作 */
void sema_down(struct semaphore* psema) {
/* 关中断来保证原子操作 */
enum intr_status old_status = intr_disable();
while(psema->value == 0) { // 若value为0,表示已经被别人持有
ASSERT(!elem_find(&psema->waiters, &running_thread()->general_tag));
/* 当前线程不应该已在信号量的waiters队列中 */
if (elem_find(&psema->waiters, &running_thread()->general_tag)) {
PANIC("sema_down: thread blocked has been in waiters_list\n");
}
/* 若信号量的值等于0,则当前线程把自己加入该锁的等待队列,然后阻塞自己 */
list_append(&psema->waiters, &running_thread()->general_tag);
thread_block(TASK_BLOCKED); // 阻塞线程,直到被唤醒
}
/* 若value为1或被唤醒后,会执行下面的代码,也就是获得了锁。*/
psema->value--;
ASSERT(psema->value == 0);
/* 恢复之前的中断状态 */
intr_set_status(old_status);
}
信号量的up操作
增加操作 up:
- 将信号量的值加1。
- 唤醒在此信号量上等待的线程。从waiters中取第一个,即每次只唤醒一个。
/* 信号量的up操作 */
void sema_up(struct semaphore* psema) {
/* 关中断,保证原子操作 */
enum intr_status old_status = intr_disable();
ASSERT(psema->value == 0);
if (!list_empty(&psema->waiters)) {
struct task_struct* thread_blocked = elem2entry(struct task_struct, general_tag, list_pop(&psema->waiters));
thread_unblock(thread_blocked);
}
psema->value++;
ASSERT(psema->value == 1);
/* 恢复之前的中断状态 */
intr_set_status(old_status);
}
锁的实现
锁结构
成员holder_repeat_nr用来累积锁的持有者重复申请锁的次数,释放锁的时候会参考此变量的值。原因是一般情况下我们应该在进入临界区之前加锁,但有时候可能持有了某临界区的锁后,在未释放锁之前,有可能会再次调用重复申请此锁的函数,这样一来,内外层函数在释放锁时会对同一个锁释放两次。(可重入)
/* 锁结构 */
struct lock {
struct task_struct* holder; // 锁的持有者,线程
struct semaphore semaphore; // 用二元信号量实现锁
uint32_t holder_repeat_nr; // 锁的持有者重复申请锁的次数
};
初始化
/* 初始化锁plock */
void lock_init(struct lock* plock) {
plock->holder = NULL;
plock->holder_repeat_nr = 0;
sema_init(&plock->semaphore, 1); // 信号量初值为1
}
获取锁
lock_acquire,如果是锁的拥有者,次数加1,可重入。如果不是锁的拥有者,执行信号量减操作,如果成功,锁的拥有者设置为当前线程,次数为1。
/* 获取锁plock */
void lock_acquire(struct lock* plock) {
if (plock->holder != running_thread()) {
sema_down(&plock->semaphore); // 对信号量P操作,原子操作
plock->holder = running_thread();
ASSERT(plock->holder_repeat_nr == 0);
plock->holder_repeat_nr = 1;
} else {
/* 排除曾经自己已经持有锁但还未将其释放的情况。*/
plock->holder_repeat_nr++;
}
}
释放锁
lock_release,锁的持有者设置为null,次数为0,执行信号量加操作。
/* 释放锁plock */
void lock_release(struct lock* plock) {
ASSERT(plock->holder == running_thread());
if (plock->holder_repeat_nr > 1) {
plock->holder_repeat_nr--;
return;
}
ASSERT(plock->holder_repeat_nr == 1);
plock->holder = NULL; // 把锁的持有者置空放在V操作之前
plock->holder_repeat_nr = 0;
sema_up(&plock->semaphore); // 信号量的V操作,也是原子操作
}
环形输入缓冲区
输入单个字符没有实际用途,一般需要输入多个字符,以回车键结束,然后解析完整的一串字符。所以需要一个缓冲区暂存输入,回车后一起处理。
生产者与消费者问题简述
线程同步:同步指按预定的先后次序进行运行,指多个线程相互协作,共同完成一个任务,属于线程间工作步调的相互制约。生产者消费者是线程同步的经典例子。
生产者与消费者问题是描述多个线程协同工作的模型,当初是由荷兰Dijkstra 为演示信号量而提出的,信号量解决了协同工作中的“同步”和“互斥”。
生产者消费者问题:对于有限大小的公共缓冲区,如何同步生产者与消费者的运行,以达到对共同缓冲区的互斥访问,并且保证生产者不会过度生产,消费者不会过度消费,缓冲区不会被破坏。
环形缓冲区的实现
环形缓冲区是个线性队列,首尾相连可实现环形。
lock 是本缓冲区的锁,每次对缓冲区操作时都要先申请这个锁,从而保证缓冲区操作互斥。
producer 是生产者,此项来记录当缓冲区满时,在此缓冲区睡眠的生产者线程。
consumer 是消费者,此项来记录当缓冲区空时,在此缓冲区睡眠的消费者线程。
buf[bufsize] 是定义的缓冲区数组,其大小为 bufsize。
head 是缓冲区队列的队首地址, tail 是队尾地址
/* 环形队列 */
struct ioqueue {
// 生产者消费者问题
struct lock lock;
/* 生产者,缓冲区不满时就继续往里面放数据,
* 否则就睡眠,此项记录哪个生产者在此缓冲区上睡眠。*/
struct task_struct* producer;
/* 消费者,缓冲区不空时就继续从往里面拿数据,
* 否则就睡眠,此项记录哪个消费者在此缓冲区上睡眠。*/
struct task_struct* consumer;
char buf[bufsize]; // 缓冲区大小
int32_t head; // 队首,数据往队首处写入
int32_t tail; // 队尾,数据从队尾处读出
};
/* 初始化io队列ioq */
void ioqueue_init(struct ioqueue* ioq) {
lock_init(&ioq->lock); // 初始化io队列的锁
ioq->producer = ioq->consumer = NULL; // 生产者和消费者置空
ioq->head = ioq->tail = 0; // 队列的首尾指针指向缓冲区数组第0个位置
}
/* 返回pos在缓冲区中的下一个位置值 */
static int32_t next_pos(int32_t pos) {
return (pos + 1) % bufsize;
}
/* 判断队列是否已满 */
bool ioq_full(struct ioqueue* ioq) {
ASSERT(intr_get_status() == INTR_OFF);
return next_pos(ioq->head) == ioq->tail;
}
/* 判断队列是否已空 */
static bool ioq_empty(struct ioqueue* ioq) {
ASSERT(intr_get_status() == INTR_OFF);
return ioq->head == ioq->tail;
}
ioq_putchar
生产者写入字符
- 如果缓冲区已满,获取缓冲区的锁(缓冲区的锁,每次对缓冲区操作时都要先申请这个锁,从而保证缓冲区操作互斥。),然后阻塞该线程(阻塞并不一定跟锁有关),被唤醒后才能释放缓冲区的锁。缓冲区满的时候才获取锁。
- 如果缓冲区未满,写入字符。如果有消费者在等待,唤醒消费者。
/* 生产者往ioq队列中写入一个字符byte */
void ioq_putchar(struct ioqueue* ioq, char byte) {
ASSERT(intr_get_status() == INTR_OFF);
/* 1.若缓冲区(队列)已经满了,获取锁
* 2.把生产者ioq->producer记为自己(为的是当缓冲区里的东西被消费者取完后让消费者知道唤醒哪个生产者),阻塞当前线程
* 3.被消费者唤醒后,释放锁
*/
while (ioq_full(ioq)) {
lock_acquire(&ioq->lock);
ioq_wait(&ioq->producer);
lock_release(&ioq->lock);
}
ioq->buf[ioq->head] = byte; // 把字节放入缓冲区中
ioq->head = next_pos(ioq->head); // 把写游标移到下一位置
if (ioq->consumer != NULL) {
wakeup(&ioq->consumer); // 唤醒消费者
}
}
/* 使当前生产者或消费者在此缓冲区上等待 */
static void ioq_wait(struct task_struct** waiter) {
ASSERT(*waiter == NULL && waiter != NULL);
*waiter = running_thread();
thread_block(TASK_BLOCKED);
}
/* 唤醒waiter */
static void wakeup(struct task_struct** waiter) {
ASSERT(*waiter != NULL);
thread_unblock(*waiter);
*waiter = NULL;
}
消费者读取字符
/* 消费者从ioq队列中获取一个字符 */
char ioq_getchar(struct ioqueue* ioq) {
ASSERT(intr_get_status() == INTR_OFF);
/* 1.若缓冲区(队列)为空,获取锁
* 2.把消费者ioq->consumer记为当前线程自己(目的是将来生产者往缓冲区里装商品后,生产者知道唤醒哪个消费者),阻塞当前线程
* 3.被生产者唤醒后,释放锁
* */
while (ioq_empty(ioq)) {
lock_acquire(&ioq->lock);
ioq_wait(&ioq->consumer);
lock_release(&ioq->lock);
}
char byte = ioq->buf[ioq->tail]; // 从缓冲区中取出
ioq->tail = next_pos(ioq->tail); // 把读游标移到下一位置
if (ioq->producer != NULL) {
wakeup(&ioq->producer); // 唤醒生产者
}
return byte;
}
总结:缓冲区满的时候或空的时候(到达边界值),第一步获取锁,第二步阻塞当前线程,让出cpu,等待其他线程工作后唤醒阻塞线程。