博客记录-day043-三种生产者-消费者模式代码+设备管理

130 阅读31分钟

一、沉默王二-并发编程

1、生产者-消费者模式

所谓的生产者-消费者,实际上包含了两类线程,一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个仓库,生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为;而消费者只需要从共享数据区中获取数据,不需要关心生产者的行为。

这个共享数据区域中应该具备这样的线程间并发协作功能:

  1. 如果共享数据区已满的话,阻塞生产者继续生产数据
  2. 如果共享数据区为空的话,阻塞消费者继续消费数据

在实现生产者消费者问题时,可以采用三种方式:

  1. 使用 Object 的 wait/notify 的消息通知机制;
  2. 使用 Lock Condition 的 await/signal 消息通知机制;
  3. 使用 BlockingQueue 实现。

1.1 wait/notify 的消息通知机制

可以通过 Object 对象的 wait 方法和 notify 方法或 notifyAll 方法来实现线程间的通信。

调用 wait 方法将阻塞当前线程,直到其他线程调用了 notify 方法或 notifyAll 方法进行通知,当前线程才能从 wait 方法处返回,继续执行下面的操作。

这些知识我们在讲 Condition 的时候其实讲到过,相信大家都还有印象。

01、wait

该方法用来将当前线程置入休眠状态,直到接到通知或被中断为止。

在调用 wait 之前,线程必须获得该对象的监视器锁,即只能在同步方法或同步块中调用 wait 方法。调用 wait 方法之后,当前线程会释放锁。如果调用 wait 方法时,线程并未获取到锁的话,则会抛出 IllegalMonitorStateException异常。如果再次获取到锁的话,当前线程才能从 wait 方法处成功返回。

02、notify

该方法也需要在同步方法或同步块中调用,即在调用前,线程也必须获得该对象的对象级别锁,如果调用 notify 时没有持有适当的锁,也会抛出 IllegalMonitorStateException

该方法会从 WAITTING 状态的线程中挑选一个进行通知,使得调用 wait 方法的线程从等待队列移入到同步队列中,等待机会再一次获取到锁,从而使得调用 wait 方法的线程能够从 wait 方法处退出。

调用 notify 后,当前线程不会马上释放该对象锁,要等到程序退出同步块后,当前线程才会释放锁。

03、notifyAll

该方法与 notify 方法的工作方式相同,重要的一点差异是:notifyAll 会使所有原来在该对象上 wait 线程统统退出 WAITTING 状态,使得他们全部从等待队列中移入到同步队列中去,等待下一次获取到对象监视器锁的机会。

不过,wait/notify 消息通知存在这样一些问题。

1.1.1 notify 早期通知

notify 通知的遗漏,即 threadA 还没开始 wait,threadB 已经 notify 了,这样,threadB 通知是没有任何响应的,当 threadB 退出 synchronized 代码块后,threadA 再开始 wait,便会一直阻塞等待,直到被别的线程打断。

示例中开启了两个线程,一个是 WaitThread,另一个是 NotifyThread。NotifyThread 会先启动调用 notify 方法。然后 WaitThread 线程才启动,调用 wait 方法,但由于通知过了,wait 方法就无法再获取到相应的通知,因此 WaitThread 会一直在 wait 方法处阻塞,这种现象就是通知过早的现象。

针对这种问题的解决方法是,添加一个状态标志,让 waitThread 调用 wait 方法前先判断状态是否已经改变了,如果通知已经发出,WaitThread 就不再去 wait。

这段代码只增加了一个isWait状态,NotifyThread 调用 notify 方法后会对状态进行更新,WaitThread 调用 wait 方法之前会先对状态进行判断。

该示例中,调用 notify 后将状态isWait改变为 false,因此,在 WaitThread 中 while 对 isWait 判断后就不会执行 wait 方法,从而避免了 Notify 过早通知造成遗漏的情况。

总结:在使用线程的等待/通知机制时,一般都要配合一个 boolean 变量值,在 notify 之前改变该 boolean 变量的值,让 wait 返回后能够退出 while 循环,或在通知被遗漏后不会被阻塞在 wait 方法处。

1.1.2 等待 wait 的条件发生变化

如果线程在等待时接收到了通知,但是之后等待的条件发生了变化,并没有再次对等待条件进行判断,也会导致程序出现错误。

在这个例子中,一共开启了 3 个线程,Consumer1,Consumer2 以及 Productor。

Consumer1 调用了 wait 方法后,线程处于了 WAITTING 状态,并且将对象锁释放。

此时,Consumer2 获取到对象锁,进入到同步代块中,当执行到 wait 方法时,同样的也会释放对象锁。

然后 productor 获取到对象锁,进入到同步代码块中,向 list 中插入数据,通过 notifyAll 方法通知处于 WAITING 状态的 Consumer1 和 Consumer2 线程。

consumer1 得到对象锁后,从 wait 方法处退出,删除一个元素让 List 为空,方法执行结束,退出同步块,释放掉对象锁。

这个时候 Consumer2 获取到对象锁后,从 wait 方法退出,继续往下执行,这个时候 Consumer2 再执行lock.remove(0);就会出错,因为 List 已经为空了。

解决方案:  通过上面的分析,可以看出 Consumer2 报错是因为线程从 wait 方法退出之后没有对 wait条件进行判断,但此时的 wait 条件已经发生了变化。解决办法就是在 wait 退出之后再对条件进行判断。

总结:在使用线程的等待/通知机制时,一般都要在 while 循环中调用 wait 方法,因此需要配合一个 boolean 变量,满足 while 循环的条件时进入 while 循环,执行 wait 方法,不满足 while 循环条件时,跳出循环,执行后面的代码。

1.1.3 “假死”状态

现象:如果是多消费者和多生产者情况,使用 notify 方法可能会出现“假死”的情况,即所有的线程都处于等待状态,无法被唤醒。

原因分析:假设当前有多个生产者线程调用了 wait 方法阻塞等待,其中一个生产者线程获取到对象锁之后使用 notify 通知处于 WAITTING 状态的线程,如果唤醒的仍然是生产者线程,就会造成所有的生产者线程都处于等待状态。

解决办法:将 notify 方法替换成 notifyAll 方法,如果使用的是 lock 的话,就将 signal 方法替换成 signalAll 方法。

总结:Object 提供的消息通知机制应该遵循如下这些条件:

  1. 永远在 while 循环中对条件进行判断而不是在 if 语句中进行 wait 条件的判断
  2. 使用 NotifyAll 而不是使用 notify。

1.2 wait/notifyAll 实现生产者-消费者

利用 wait/notifyAll 实现生产者和消费者代码如下:

public class ProductorConsumer {
    // 主方法
    public static void main(String[] args) {

        // 创建一个LinkedList作为生产者和消费者共享的数据结构
        LinkedList linkedList = new LinkedList();
        // 创建一个固定大小为15的线程池
        ExecutorService service = Executors.newFixedThreadPool(15);

        // 启动5个生产者线程
        for (int i = 0; i < 5; i++) {
            service.submit(new Productor(linkedList, 8));
        }

        // 启动10个消费者线程
        for (int i = 0; i < 10; i++) {
            service.submit(new Consumer(linkedList));
        }

    }

    // 生产者类,实现Runnable接口
    static class Productor implements Runnable {

        // 生产者操作的数据列表
        private List<Integer> list;
        // 数据列表的最大长度
        private int maxLength;

        // 构造方法,初始化数据列表和最大长度
        public Productor(List list, int maxLength) {
            this.list = list;
            this.maxLength = maxLength;
        }

        // 运行方法,生产者逻辑
        @Override
        public void run() {
            while (true) {
                // 对数据列表进行同步操作
                synchronized (list) {
                    try {
                        // 如果数据列表达到最大长度,则生产者等待
                        while (list.size() == maxLength) {
                            System.out.println("生产者" + Thread.currentThread().getName() + "  list以达到最大容量,进行wait");
                            list.wait();
                            System.out.println("生产者" + Thread.currentThread().getName() + "  退出wait");
                        }
                        // 生成随机数作为生产数据
                        Random random = new Random();
                        int i = random.nextInt();
                        System.out.println("生产者" + Thread.currentThread().getName() + " 生产数据" + i);
                        // 将生产的数据添加到数据列表
                        list.add(i);
                        // 通知所有等待的线程(生产者和消费者)
                        list.notifyAll();
                    } catch (InterruptedException e) {
                        // 异常处理
                        e.printStackTrace();
                    }
                }

            }
        }
    }

    // 消费者类,实现Runnable接口
    static class Consumer implements Runnable {

        // 消费者操作的数据列表
        private List<Integer> list;

        // 构造方法,初始化数据列表
        public Consumer(List list) {
            this.list = list;
        }

        // 运行方法,消费者逻辑
        @Override
        public void run() {
            while (true) {
                // 对数据列表进行同步操作
                synchronized (list) {
                    try {
                        // 如果数据列表为空,则消费者等待
                        while (list.isEmpty()) {
                            System.out.println("消费者" + Thread.currentThread().getName() + "  list为空,进行wait");
                            list.wait();
                            System.out.println("消费者" + Thread.currentThread().getName() + "  退出wait");
                        }
                        // 从数据列表移除一个元素(模拟消费)
                        Integer element = list.remove(0);
                        System.out.println("消费者" + Thread.currentThread().getName() + "  消费数据:" + element);
                        // 通知所有等待的线程(生产者和消费者)
                        list.notifyAll();
                    } catch (InterruptedException e) {
                        // 异常处理
                        e.printStackTrace();
                    }
                }
            }
        }
    }

}

输出结果:

生产者pool-1-thread-1 生产数据-232820990
生产者pool-1-thread-1 生产数据1432164130
生产者pool-1-thread-1 生产数据1057090222
生产者pool-1-thread-1 生产数据1201395916
生产者pool-1-thread-1 生产数据482766516
生产者pool-1-thread-1  list以达到最大容量,进行wait
消费者pool-1-thread-15  退出wait
消费者pool-1-thread-15  消费数据:1237535349
消费者pool-1-thread-15  消费数据:-1617438932
消费者pool-1-thread-15  消费数据:-535396055
消费者pool-1-thread-15  消费数据:-232820990
消费者pool-1-thread-15  消费数据:1432164130
消费者pool-1-thread-15  消费数据:1057090222
消费者pool-1-thread-15  消费数据:1201395916
消费者pool-1-thread-15  消费数据:482766516
消费者pool-1-thread-15  list为空,进行wait
生产者pool-1-thread-5  退出wait
生产者pool-1-thread-5 生产数据1442969724
生产者pool-1-thread-5 生产数据1177554422
生产者pool-1-thread-5 生产数据-133137235
生产者pool-1-thread-5 生产数据324882560
生产者pool-1-thread-5 生产数据2065211573
生产者pool-1-thread-5 生产数据253569900
生产者pool-1-thread-5 生产数据571277922
生产者pool-1-thread-5 生产数据1622323863
生产者pool-1-thread-5  list以达到最大容量,进行wait
消费者pool-1-thread-10  退出wait

1.3 await/signalAll 实现生产者-消费者

参照 Object 的 wait 和 notify/notifyAll 方法,Condition 也提供了同样的方法,即 await 方法和 signal/signalAll 方法。这部分知识我们前面在讲 Condition 的时候也讲到过,相信大家都还有印象。

那如果采用 Conditon 的消息通知原理来实现生产者-消费者模型,原理同使用 wait/notifyAll 一样。直接上代码:

public class ProductorConsumer {

    // 定义一个可重入锁
    private static ReentrantLock lock = new ReentrantLock();
    // 定义一个条件,表示队列已满
    private static Condition full = lock.newCondition();
    // 定义一个条件,表示队列为空
    private static Condition empty = lock.newCondition();

    public static void main(String[] args) {
        // 创建一个链表作为生产者和消费者之间的队列
        LinkedList<Integer> linkedList = new LinkedList<>();
        // 创建一个固定大小的线程池
        ExecutorService service = Executors.newFixedThreadPool(15);
        // 启动5个生产者线程
        for (int i = 0; i < 5; i++) {
            service.submit(new Productor(linkedList, 8, lock));
        }
        // 启动10个消费者线程
        for (int i = 0; i < 10; i++) {
            service.submit(new Consumer(linkedList, lock));
        }

    }

    // 生产者类,实现Runnable接口
    static class Productor implements Runnable {

        // 队列
        private List<Integer> list;
        // 队列最大长度
        private int maxLength;
        // 锁
        private Lock lock;

        public Productor(List<Integer> list, int maxLength, Lock lock) {
            this.list = list;
            this.maxLength = maxLength;
            this.lock = lock;
        }

        @Override
        public void run() {
            while (true) {
                // 获取锁
                lock.lock();
                try {
                    // 如果队列已满,则等待
                    while (list.size() == maxLength) {
                        System.out.println("生产者" + Thread.currentThread().getName() + "  list已达到最大容量,进行wait");
                        full.await();
                        System.out.println("生产者" + Thread.currentThread().getName() + "  退出wait");
                    }
                    // 生成随机数并添加到队列
                    Random random = new Random();
                    int i = random.nextInt();
                    System.out.println("生产者" + Thread.currentThread().getName() + " 生产数据" + i);
                    list.add(i);
                    // 唤醒所有等待的消费者
                    empty.signalAll();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 释放锁
                    lock.unlock();
                }
            }
        }
    }

    // 消费者类,实现Runnable接口
    static class Consumer implements Runnable {

        // 队列
        private List<Integer> list;
        // 锁
        private Lock lock;

        public Consumer(List<Integer> list, Lock lock) {
            this.list = list;
            this.lock = lock;
        }

        @Override
        public void run() {
            while (true) {
                // 获取锁
                lock.lock();
                try {
                    // 如果队列为空,则等待
                    while (list.isEmpty()) {
                        System.out.println("消费者" + Thread.currentThread().getName() + "  list为空,进行wait");
                        empty.await();
                        System.out.println("消费者" + Thread.currentThread().getName() + "  退出wait");
                    }
                    // 从队列中取出一个元素
                    Integer element = list.remove(0);
                    System.out.println("消费者" + Thread.currentThread().getName() + "  消费数据:" + element);
                    // 唤醒所有等待的生产者
                    full.signalAll();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 释放锁
                    lock.unlock();
                }
            }
        }
    }

}

输出结果:

消费者pool-1-thread-9  消费数据:1146627506
消费者pool-1-thread-9  消费数据:1508001019
消费者pool-1-thread-9  消费数据:-600080565
消费者pool-1-thread-9  消费数据:-1000305429
消费者pool-1-thread-9  消费数据:-1270658620
消费者pool-1-thread-9  消费数据:1961046169
消费者pool-1-thread-9  消费数据:-307680655
消费者pool-1-thread-9  list为空,进行wait
消费者pool-1-thread-13  退出wait
消费者pool-1-thread-13  list为空,进行wait
消费者pool-1-thread-10  退出wait
生产者pool-1-thread-5  退出wait
生产者pool-1-thread-5 生产数据-892558288
生产者pool-1-thread-5 生产数据-1917220008
生产者pool-1-thread-5 生产数据2146351766
生产者pool-1-thread-5 生产数据452445380
生产者pool-1-thread-5 生产数据1695168334
生产者pool-1-thread-5 生产数据1979746693
生产者pool-1-thread-5 生产数据-1905436249
生产者pool-1-thread-5 生产数据-101410137
生产者pool-1-thread-5  list以达到最大容量,进行wait
生产者pool-1-thread-1  退出wait
生产者pool-1-thread-1  list以达到最大容量,进行wait
生产者pool-1-thread-4  退出wait
生产者pool-1-thread-4  list以达到最大容量,进行wait
生产者pool-1-thread-2  退出wait
生产者pool-1-thread-2  list以达到最大容量,进行wait
生产者pool-1-thread-3  退出wait
生产者pool-1-thread-3  list以达到最大容量,进行wait
消费者pool-1-thread-9  退出wait
消费者pool-1-thread-9  消费数据:-892558288

1.4 BlockingQueue 实现生产者-消费者

BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。

有了这个队列,生产者就只需要关注生产,而不用管消费者的消费行为,更不用等待消费者线程执行完;消费者也只管消费,不用管生产者是怎么生产的,更不用等着生产者生产。

下面直接上代码:

// 定义一个生产者消费者问题示例的主类
public class ProductorConsumer {

    // 创建一个线程安全的链表阻塞队列,用于存储生产者生产的数据
    private static LinkedBlockingQueue<Integer> queue = new LinkedBlockingQueue<>();

    // 程序的入口点
    public static void main(String[] args) {
        // 创建一个固定大小的线程池,用于执行生产者和消费者的任务
        ExecutorService service = Executors.newFixedThreadPool(15);
        // 启动5个生产者线程
        for (int i = 0; i < 5; i++) {
            service.submit(new Productor(queue));
        }
        // 启动10个消费者线程
        for (int i = 0; i < 10; i++) {
            service.submit(new Consumer(queue));
        }
    }

    // 定义一个生产者类,实现Runnable接口
    static class Productor implements Runnable {

        // 定义一个阻塞队列,用于存储生产的数据
        private BlockingQueue queue;

        // 生产者的构造方法,接收一个阻塞队列作为参数
        public Productor(BlockingQueue queue) {
            this.queue = queue;
        }

        // 重写run方法,实现生产者的业务逻辑
        @Override
        public void run() {
            try {
                // 无限循环,模拟生产者不断生产数据
                while (true) {
                    // 创建一个随机数生成器
                    Random random = new Random();
                    // 生成一个随机数
                    int i = random.nextInt();
                    // 打印生产者线程名称和生产的数据
                    System.out.println("生产者" + Thread.currentThread().getName() + "生产数据" + i);
                    // 将数据放入阻塞队列中
                    queue.put(i);
                }
            } catch (InterruptedException e) {
                // 捕获并处理中断异常
                e.printStackTrace();
            }
        }
    }

    // 定义一个消费者类,实现Runnable接口
    static class Consumer implements Runnable {
        // 定义一个阻塞队列,用于获取生产的数据
        private BlockingQueue queue;

        // 消费者的构造方法,接收一个阻塞队列作为参数
        public Consumer(BlockingQueue queue) {
            this.queue = queue;
        }

        // 重写run方法,实现消费者的业务逻辑
        @Override
        public void run() {
            try {
                // 无限循环,模拟消费者不断消费数据
                while (true) {
                    // 从阻塞队列中获取数据,如果队列为空,则线程会阻塞等待
                    Integer element = (Integer) queue.take();
                    // 打印消费者线程名称和消费的数据
                    System.out.println("消费者" + Thread.currentThread().getName() + "正在消费数据" + element);
                }
            } catch (InterruptedException e) {
                // 捕获并处理中断异常
                e.printStackTrace();
            }
        }
    }

}

输出结果:

消费者pool-1-thread-7正在消费数据1520577501
生产者pool-1-thread-4生产数据-127809610
消费者pool-1-thread-8正在消费数据504316513
生产者pool-1-thread-2生产数据1994678907
消费者pool-1-thread-11正在消费数据1967302829
生产者pool-1-thread-1生产数据369331507
消费者pool-1-thread-9正在消费数据1994678907
生产者pool-1-thread-2生产数据-919544017
消费者pool-1-thread-12正在消费数据-127809610
生产者pool-1-thread-4生产数据1475197572
消费者pool-1-thread-14正在消费数据-893487914
生产者pool-1-thread-3生产数据906921688
消费者pool-1-thread-6正在消费数据-1292015016
生产者pool-1-thread-5生产数据-652105379
生产者pool-1-thread-5生产数据-1622505717
生产者pool-1-thread-3生产数据-1350268764
消费者pool-1-thread-7正在消费数据906921688
生产者pool-1-thread-4生产数据2091628867
消费者pool-1-thread-13正在消费数据1475197572
消费者pool-1-thread-15正在消费数据-919544017
生产者pool-1-thread-2生产数据564860122
生产者pool-1-thread-2生产数据822954707
消费者pool-1-thread-14正在消费数据564860122
消费者pool-1-thread-10正在消费数据369331507
生产者pool-1-thread-1生产数据-245820912
消费者pool-1-thread-6正在消费数据822954707
生产者pool-1-thread-2生产数据1724595968
生产者pool-1-thread-2生产数据-1151855115
消费者pool-1-thread-12正在消费数据2091628867
生产者pool-1-thread-4生产数据-1774364499
生产者pool-1-thread-4生产数据2006106757
消费者pool-1-thread-14正在消费数据-1774364499
生产者pool-1-thread-3生产数据-1070853639
消费者pool-1-thread-9正在消费数据-1350268764
消费者pool-1-thread-11正在消费数据-1622505717
生产者pool-1-thread-5生产数据355412953

可以看出,使用 BlockingQueue 来实现生产者-消费者很简洁,这正是 BlockingQueue 的优势所在。

二、小林-图解系统-设备管理

1、设备控制器

我们的电脑设备可以接非常多的输入输出设备,比如键盘、鼠标、显示器、网卡、硬盘、打印机、音响等等,每个设备的用法和功能都不同,那操作系统是如何把这些输入输出设备统一管理的呢?

为了屏蔽设备之间的差异,每个设备都有一个叫设备控制器(Device Control  的组件,比如硬盘有硬盘控制器、显示器有视频控制器等。

计算机 I/O 系统结构

因为这些控制器都很清楚的知道对应设备的用法和功能,所以 CPU 是通过设备控制器来和设备打交道的。

设备控制器里有芯片,它可执行自己的逻辑,也有自己的寄存器,用来与 CPU 进行通信,比如:

  • 通过写入这些寄存器,操作系统可以命令设备发送数据、接收数据、开启或关闭,或者执行某些其他操作。
  • 通过读取这些寄存器,操作系统可以了解设备的状态,是否准备好接收一个新的命令等。

实际上,控制器是有三类寄存器,它们分别是状态寄存器(Status Register) 、 命令寄存器(Command Register)以及数据寄存器(Data Register) ,如下图:

这三个寄存器的作用:

  • 数据寄存器CPU 向 I/O 设备写入需要传输的数据,比如要打印的内容是「Hello」,CPU 就要先发送一个 H 字符给到对应的 I/O 设备。
  • 命令寄存器CPU 发送一个命令,告诉 I/O 设备,要进行输入/输出操作,于是就会交给 I/O 设备去工作,任务完成后,会把状态寄存器里面的状态标记为完成。
  • 状态寄存器,目的是告诉 CPU ,现在已经在工作或工作已经完成,如果已经在工作状态,CPU 再发送数据或者命令过来,都是没有用的,直到前面的工作已经完成,状态寄存标记成已完成,CPU 才能发送下一个字符和命令。

CPU 通过读写设备控制器中的寄存器控制设备,这可比 CPU 直接控制输入输出设备,要方便和标准很多。

另外, 输入输出设备可分为两大类 :块设备(Block Device)字符设备(Character Device)

  • 块设备,把数据存储在固定大小的块中,每个块有自己的地址,硬盘、USB 是常见的块设备。
  • 字符设备,以字符为单位发送或接收一个字符流,字符设备是不可寻址的,也没有任何寻道操作,鼠标是常见的字符设备。

块设备通常传输的数据量会非常大,于是控制器设立了一个可读写的数据缓冲区

  • CPU 写入数据到控制器的缓冲区时,当缓冲区的数据囤够了一部分,才会发给设备。
  • CPU 从控制器的缓冲区读取数据时,也需要缓冲区囤够了一部分,才拷贝到内存。

这样做是为了,减少对设备的频繁操作。

那 CPU 是如何与设备的控制寄存器和数据缓冲区进行通信的?存在两个方法:

  • 端口 I/O,每个控制寄存器被分配一个 I/O 端口,可以通过特殊的汇编指令操作这些寄存器,比如 in/out 类似的指令。
  • 内存映射 I/O,将所有控制寄存器映射到内存空间中,这样就可以像读写内存一样读写数据缓冲区。

2、I/O 控制方式

在前面我知道,每种设备都有一个设备控制器,控制器相当于一个小 CPU,它可以自己处理一些事情,但有个问题是,当 CPU 给设备发送了一个指令,让设备控制器去读设备的数据,它读完的时候,要怎么通知 CPU 呢?

控制器的寄存器一般会有状态标记位,用来标识输入或输出操作是否完成。于是,我们想到第一种轮询等待的方法,让 CPU 一直查寄存器的状态,直到状态标记为完成,很明显,这种方式非常的傻瓜,它会占用 CPU 的全部时间。

那我们就想到第二种方法 —— 中断,通知操作系统数据已经准备好了。我们一般会有一个硬件的中断控制器,当设备完成任务后触发中断到中断控制器,中断控制器就通知 CPU,一个中断产生了,CPU 需要停下当前手里的事情来处理中断。

另外,中断有两种,一种软中断,例如代码调用 INT 指令触发,一种是硬件中断,就是硬件通过中断控制器触发的。

中断的方式对于频繁读写数据的磁盘,并不友好,这样 CPU 容易经常被打断,会占用 CPU 大量的时间。对于这一类设备的问题的解决方法是使用 DMA(Direct Memory Access)  功能,它可以使得设备在 CPU 不参与的情况下,能够自行完成把设备 I/O 数据放入到内存。那要实现 DMA 功能要有 「DMA 控制器」硬件的支持。

DMA 的工作方式如下:

  • CPU 需对 DMA 控制器下发指令,告诉它想读取多少数据,读完的数据放在内存的某个地方就可以了;
  • 接下来,DMA 控制器会向磁盘控制器发出指令,通知它从磁盘读数据到其内部的缓冲区中,接着磁盘控制器将缓冲区的数据传输到内存;
  • 当磁盘控制器把数据传输到内存的操作完成后,磁盘控制器在总线上发出一个确认成功的信号到 DMA 控制器;
  • DMA 控制器收到信号后,DMA 控制器发中断通知 CPU 指令完成,CPU 就可以直接用内存里面现成的数据了;

可以看到, CPU 当要读取磁盘数据的时候,只需给 DMA 控制器发送指令,然后返回去做其他事情,当磁盘数据拷贝到内存后,DMA 控制机器通过中断的方式,告诉 CPU 数据已经准备好了,可以从内存读数据了。仅仅在传送开始和结束时需要 CPU 干预。

3、设备驱动程序

虽然设备控制器屏蔽了设备的众多细节,但每种设备的控制器的寄存器、缓冲区等使用模式都是不同的,所以为了屏蔽「设备控制器」的差异,引入了设备驱动程序

设备控制器不属于操作系统范畴,它是属于硬件,而设备驱动程序属于操作系统的一部分,操作系统的内核代码可以像本地调用代码一样使用设备驱动程序的接口,而设备驱动程序是面向设备控制器的代码,它发出操控设备控制器的指令后,才可以操作设备控制器。

不同的设备控制器虽然功能不同,但是设备驱动程序会提供统一的接口给操作系统,这样不同的设备驱动程序,就可以以相同的方式接入操作系统。如下图:

前面提到了不少关于中断的事情,设备完成了事情,则会发送中断来通知操作系统。那操作系统就需要有一个地方来处理这个中断,这个地方也就是在设备驱动程序里,它会及时响应控制器发来的中断请求,并根据这个中断的类型调用响应的中断处理程序进行处理。

通常,设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。

我们来看看,中断处理程序的处理流程:

  1. 在 I/O 时,设备控制器如果已经准备好数据,则会通过中断控制器向 CPU 发送中断请求
  2. 保护被中断进程的 CPU 上下文
  3. 转入相应的设备中断处理函数
  4. 进行中断处理
  5. 恢复被中断进程的上下文;

4、通用块层

对于块设备,为了减少不同块设备的差异带来的影响,Linux 通过一个统一的通用块层,来管理不同的块设备。

通用块层是处于文件系统和磁盘驱动中间的一个块设备抽象层,它主要有两个功能:

  • 第一个功能,向上为文件系统和应用程序,提供访问块设备的标准接口,向下把各种不同的磁盘设备抽象为统一的块设备,并在内核层面,提供一个框架来管理这些设备的驱动程序;
  • 第二功能,通用层还会给文件系统和应用程序发来的 I/O 请求排队,接着会对队列重新排序、请求合并等方式,也就是 I/O 调度,主要目的是为了提高磁盘读写的效率。

Linux 内存支持 5 种 I/O 调度算法,分别是:

  • 没有调度算法
  • 先入先出调度算法
  • 完全公平调度算法
  • 优先级调度
  • 最终期限调度算法

第一种,没有调度算法,是的,你没听错,它不对文件系统和应用程序的 I/O 做任何处理,这种算法常用在虚拟机 I/O 中,此时磁盘 I/O 调度算法交由物理机系统负责。

第二种,先入先出调度算法,这是最简单的 I/O 调度算法,先进入 I/O 调度队列的 I/O 请求先发生。

第三种,完全公平调度算法,大部分系统都把这个算法作为默认的 I/O 调度器,它为每个进程维护了一个 I/O 调度队列,并按照时间片来均匀分布每个进程的 I/O 请求。

第四种,优先级调度算法,顾名思义,优先级高的 I/O 请求先发生, 它适用于运行大量进程的系统,像是桌面环境、多媒体应用等。

第五种,最终期限调度算法,分别为读、写请求创建了不同的 I/O 队列,这样可以提高机械磁盘的吞吐量,并确保达到最终期限的请求被优先处理,适用于在 I/O 压力比较大的场景,比如数据库等。

5、存储系统 I/O 软件分层

前面说到了不少东西,设备、设备控制器、驱动程序、通用块层,现在再结合文件系统原理,我们来看看 Linux 存储系统的 I/O 软件分层。

可以把 Linux 存储系统的 I/O 由上到下可以分为三个层次,分别是文件系统层、通用块层、设备层。他们整个的层次关系如下图:

这三个层次的作用是:

  • 文件系统层,包括虚拟文件系统和其他文件系统的具体实现,它向上为应用程序统一提供了标准的文件访问接口,向下会通过通用块层来存储和管理磁盘数据。
  • 通用块层,包括块设备的 I/O 队列和 I/O 调度器,它会对文件系统的 I/O 请求进行排队,再通过 I/O 调度器,选择一个 I/O 发给下一层的设备层。
  • 设备层,包括硬件设备、设备控制器和驱动程序,负责最终物理设备的 I/O 操作。

有了文件系统接口之后,不但可以通过文件系统的命令行操作设备,也可以通过应用程序,调用 readwrite 函数,就像读写文件一样操作设备,所以说设备在 Linux 下,也只是一个特殊的文件。

但是,除了读写操作,还需要有检查特定于设备的功能和属性。于是,需要 ioctl 接口,它表示输入输出控制接口,是用于配置和修改特定设备属性的通用接口。

另外,存储系统的 I/O 是整个系统最慢的一个环节,所以 Linux 提供了不少缓存机制来提高 I/O 的效率。

  • 为了提高文件访问的效率,会使用页缓存、索引节点缓存、目录项缓存等多种缓存机制,目的是为了减少对块设备的直接调用。
  • 为了提高块设备的访问效率, 会使用缓冲区,来缓存块设备的数据。

6、键盘敲入字母时,期间发生了什么?

看完前面的内容,相信你对输入输出设备的管理有了一定的认识,那接下来就从操作系统的角度回答开头的问题「键盘敲入字母时,操作系统期间发生了什么?」

我们先来看看 CPU 的硬件架构图:

CPU 的硬件架构图

CPU 里面的内存接口,直接和系统总线通信,然后系统总线再接入一个 I/O 桥接器,这个 I/O 桥接器,另一边接入了内存总线,使得 CPU 和内存通信。再另一边,又接入了一个 I/O 总线,用来连接 I/O 设备,比如键盘、显示器等。

那当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给 CPU 发送中断请求

CPU 收到中断请求后,操作系统会保存被中断进程的 CPU 上下文,然后调用键盘的中断处理程序

键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的 ASCII 码,比如用户在键盘输入的是字母 A,是显示字符,于是就会把扫描码翻译成 A 字符的 ASCII 码。

得到了显示字符的 ASCII 码后,就会把 ASCII 码放到「读缓冲区队列」,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从「读缓冲区队列」读取数据放到「写缓冲区队列」,最后把「写缓冲区队列」的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。

显示出结果后,恢复被中断进程的上下文