真象还原笔记:锁及其在终端输出的应用

概述

  • 在上一篇笔记中,多个线程同时访问显存时会造成字符覆盖、显存段溢出的问题,虽然在最后使用前后关开中断解决了这个问题,但这种解决方式是十分粗糙的。就字符串打印函数put_str()来说,它的打印实际上是通过调用字符打印函数put_char()实现的,也就是说更优秀的并发应该是在临界区put_char()前后关闭、开启中断。这种方式是可行,可每个需要原子操作的功能都使用该方法一一封装未免太过繁琐。而且对于不调用该临界区的线程来说,只能干等着调用临界区的线程开中断,这样同样不利于高效的并发。所以在本篇笔记中将利用同步机制——锁解决这个问题,并使用它封装终端输出函数。

概念理解

信号量

  • 信号量是一个全局共享的变量,且有 updown 两个操作对它进行更改

信号量操作规则

  • up
  1. 将信号量加1。

  2. 唤醒在该信号量上阻塞的线程。

  • down
  1. 判断信号量是否大于0。

  2. 大于0,信号量减1。

  3. 等于0,将调用该操作的线程阻塞在此信号量上。

由上述操作规则可见,信号量能够有效地解决我们遇到的问题,但对 updown 两个操作来说,它门必须是原子的,而在信号量还未实现的时候,原子操作只能使用关闭、开启中断来完成。虽然这样会使不修改信号量的线程也陷入等待,但这却意味着从此之后,使用信号量封装的临界区不会再使不调用它的线程陷入这种 “无缘无故” 的等待。

具体实现

  • 借助二元信号量实现:
//信号量结构体
typedef struct semaphore {
    uint8_t value;      //信号量值
    list waiters;       //信号量等待队列
} semaphore;

//锁结构体
typedef struct lock {
    task_struct *holder;        //拥有该锁的线程
    semaphore semaphore;        //锁的信号量
    uint32_t holder_repeat_nr;  //拥有该锁的线程重复申请该锁的次数
} lock;
  • 信号量和锁的初始化函数:
//初始化信号量
void sema_init(semaphore *psema, uint8_t value) {
    //信号量值赋值
    psema->value = value;
    //初始化信号量等待队列
    list_init(&psema->waiters);
}

//初始化锁plock
void lock_init(lock *plock) {
    plock->holder = NULL;
    plock->holder_repeat_nr = 0;
    //使用二元信号量
    sema_init(&plock->semaphore, 1);
}

初始化函数的分开实现,有利于后期使用多元信号量时的扩展。

  • 线程的阻塞与唤醒:
//阻塞当前运行的线程,将其状态置为stat
void thread_block(task_status stat) {
    //检测将置为的状态是否符合阻塞要求
    ASSERT(stat == TASK_BLOCKED || stat == TASK_WAITING || stat == TASK_HANGING);
    //关闭中断,保证阻塞操作的原子性
    intr_status old_status = intr_disable();
    
    //将当前线程的状态设为stat
    task_struct *cur_thread = running_thread();
    cur_thread->status = stat;
    //调用任务调度器
    schedule();
    
    //恢复中断状态(在下次该线程被调上CPU时执行)
    intr_set_status(old_status);
}

//唤醒线程pthread
void thread_unblock(task_struct *pthread) {
    //检测pthread是否符合阻塞状态线程
    ASSERT(pthread->status == TASK_BLOCKED || pthread->status == TASK_HANGING || pthread->status == TASK_WAITING);
    
    //关闭中断,保证唤醒操作的原子性
    intr_status old_status = intr_disable();
    
    //pthread不为就绪态
    if (pthread->status != TASK_READY) {
        
        ASSERT(!elem_find(&thread_ready_list, &pthread->general_tag));
        //若阻塞的pthread出现在就绪队列中
        if (elem_find(&thread_ready_list, &pthread->general_tag)){
            PANIC("thread_unblock: blocked thread in ready_list\n");
        }
        
        //修改pthread状态
        pthread->status = TASK_READY;
        //将pthread加入就绪队列队首,使其尽快被调上CPU
        list_push(&thread_ready_list, &pthread->general_tag);
    }
    
    //恢复中断状态
    intr_set_status(old_status);
}

看到这段代码的实现,不知道有没有哪位有这样的疑惑: “线程调度相关的很多操作也是原子的,需不需要也用信号量封装一下,提高下效率” 。首先需要清楚的是,线程调度操作的共享资源——就绪队列等,是所有线程都必须访问的共享资源,也就是说只要有线程在进行调度操作,所有线程都得停下,关中断状态在这种情况下就体现出了优势,因此我们也就不需要多此一举。

  • 信号量的 updown 操作:
//信号量up操作
void sema_up(semaphore *psema) {
    //关中断保证up操作的原子状态
    intr_status old_status = intr_disable();
    
    //信号量值必须等于0
    ASSERT(0 == psema->value);
    //该信号量的等待队列中有线程,唤醒队首线程
    if (!list_empty(&psema->waiters)) {
        //弹出等待队列队首线程
        task_struct *thread_blocked = elem2entry(task_struct, general_tag, list_pop(&psema->waiters));
        //唤醒阻塞线程
        thread_unblock(thread_blocked);
    }
    
    //信号量up,即释放了该信号量资源
    psema->value++;
    ASSERT(1 == psema->value);
    //恢复中断状态
    intr_set_status(old_status);
}

//信号量down操作
void sema_down(semaphore *psema) {
    //关中断保证down操作的原子状态
    intr_status old_status = intr_disable();
    
    //当信号量的值为0时,该信号量资源已被申请完
    while (0 == psema->value) {
        //检测当前线程是否在该信号量的等待队列中
        ASSERT(!elem_find(&psema->waiters, &running_thread()->general_tag));
        if (elem_find(&psema->waiters, &running_thread()->general_tag)) {
            PANIC("sema_down: thread blocked has been in waiters_list\n");
        }
        
        //将当前线程加入该信号量的等待队列并阻塞
        list_append(&psema->waiters, &running_thread()->general_tag);
        thread_block(TASK_BLOCKED);
    }
    
    //获取信号量资源后将该信号量down
    psema->value--;
    
    ASSERT(0 == psema->value);
    //恢复之前的中断状态
    intr_set_status(old_status);
}

down 操作中的while ()替换成if ()是否可行,这个问题的要点在于:“是否有线程先于刚被唤醒的线程申请锁”。根据本系统的调度方式来说,刚被唤醒的线程会直接插入到就绪队列队首,不会被“其他线程”提前获取锁。这样来看仿佛是可以替换的。

  • 锁的申请与释放:
//获取锁plock
void lock_acquire(lock *plock) {
    //当前线程第一次申请该锁
    if (plock->holder != running_thread()) {
        //对该锁的信号量down,即申请该锁(原子操作)
        sema_down(&plock->semaphore);
        //设置该锁的holder
        plock->holder = running_thread();
        ASSERT(0 == plock->holder_repeat_nr);
        //设当前线程对该锁的申请次数为1
        plock->holder_repeat_nr = 1;
    }
    else {
        //当前线程重复申请该锁
        plock->holder_repeat_nr++;
    }
}

//释放锁plock
void lock_release(lock *plock) {
    ASSERT(plock->holder == running_thread());
    //当前线程重复申请过该锁
    if (plock->holder_repeat_nr > 1) {
        //减小重复申请次数,并不释放该锁
        plock->holder_repeat_nr--;
        return;
    }
    ASSERT(1 == plock->holder_repeat_nr);
    
    //将该锁的holder及holder_repeat_nr重置
    plock->holder = NULL;
    plock->holder_repeat_nr = 0;
    //将该锁的信号量up,即释放该锁(原子操作)
    sema_up(&plock->semaphore);
}

终端输出封装

static lock console_lock;       //控制台锁

//初始化终端
void console_init(void) {
    lock_init(&console_lock);
}

//获取终端
void console_acquire(void) {
    lock_acquire(&console_lock);
}

//释放终端
void console_release(void) {
    lock_release(&console_lock);
}

//终端输出字符串
void console_put_str(char *str) {
    console_acquire();
    put_str(str);
    console_release();
}

//终端输出字符
void console_put_char(uint8_t char_asci) {
    console_acquire();
    put_char(char_asci);
    console_release();
}

//终端输出数字
void console_put_int(uint32_t num) {
    console_acquire();
    put_int(num);
    console_release();
}

实验检测

  • 仿照书中的main.c,整个系统跑得很稳定(让它跑了1个多小时),不过突然想起sema_down()函数中while()if()的互换,真的可以换?大可试试:
test.png

这是什么情况?难道不能换?还记得前面说的“其他线程”吗,难道还有哪个线程能插在刚刚唤醒的线程前面?嗯,CPU上不就正跑着一个么,它如果多次对锁进行申请、释放,问题就出现了。因此,这个还真换不了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容