西安工大软件构造学习笔记-MIT22-队列和消息传递

一、首先介绍两种并发编程模型的概念:

在并发编程中,通常有两种不同的模式:共享内存和消息传递。

共享内存:指的是多个线程或者进程共享同一块内存区域,并通过读写该内存区域来进行通信。这种模式的优点是简单、快速、但是也容易出现各种问题,如竞态条件和死锁等。在共享内存模型中,并发模块通过在内存中读取和写入共享可变对象进行交互。 在单个 Java 进程中创建多个线程是我们共享内存并发的主要示例。

消息传递:通过发送和接收消息进行通信,不同线程或进程之间相互独立,不直接共享内存。这种模式的优点是安全且可靠,但是消息传递的开销也会更大,占用更多的内存呗。在消息传递模型中,并发模块通过通信通道相互发送不可变消息来进行交互。 该通信通道可能通过网络连接不同的计算机,如我们最初的一些示例:网页浏览、即时消息等。

二者对比:与共享内存模型相比,消息传递模型有几个优点,归结为更高的错误安全性。 在消息传递中,并发模块通过通信通道传递消息来显式交互,而不是通过共享数据的突变进行隐式交互。 共享内存的隐式交互很容易导致无意的交互,共享和操作程序的某些部分中的数据,这些部分不知道它们是并发的,并且在线程安全策略中没有正确协作。 消息传递也只在模块之间共享不可变对象(消息),而共享内存需要共享可变对象,我们已经看到这可能是错误的根源

二、本文的主要介绍内容:

我们将在本文中讨论如何在单个进程中实现消息传递。 我们将使用阻塞队列(现有的线程安全类型)来实现进程内线程之间的消息传递。 阻塞队列的某些操作是阻塞的,因为调用该操作会阻止线程的进度,直到该操作可以返回结果。 阻塞使编写代码更容易,但这也意味着我们必须继续应对导致死锁的错误。

三、线程之间的消息传递

我们在锁和同步中看到,线程阻止尝试获取锁,直到锁被其当前所有者释放。 阻塞意味着线程等待(不做进一步的工作)直到事件发生。 我们可以用这个术语来描述方法和方法调用:如果一个方法是阻塞方法,那么对该方法的调用可以阻塞,等到某个事件发生后再返回到调用方。

我们可以使用带有阻塞操作的队列在线程之间传递消息 —— 客户端/服务器消息传递中的缓冲网络通信通道将以相同的方式工作。 Java 为具有阻塞操作的队列提供了 BlockingQueue 接口。

在普通队列中:

add(e)  将元素添加到队列的末尾。e
remove()删除并返回队列头部的元素,或者在队列为空时引发异常。

BlockingQueue 扩展了此接口还支持在检索元素时等待队列变为非空,以及在存储元素时等待队列中的空间变为可用。

put(e) 块,直到它可以将元素添加到队列的末尾(如果队列没有大小限制,则不会阻塞)。eput
take() 块,直到它可以删除并返回队列头部的元素,直到队列不为空。

在线程之间使用 for 消息传递时,请确保使用 and 操作,而不是 add() 和 remove()。BlockingQueueput()take()。

我们将为线程之间的消息传递实现生产者-消费者设计模式。 生产者线程和使用者线程共享一个同步队列。 生产者将数据或请求放入队列,消费者删除并处理它们。 一个或多个生产者和一个或多个使用者可能都在同一个队列中添加和删除项目。 此队列对于并发必须是安全的。

Java提供了以下两种实现:BlockingQueue

ArrayBlockingQueue 是一个使用数组表示形式的固定大小的队列。如果队列已满,队列中的新项目将阻塞。put
LinkedBlockingQueue 是一个使用链接列表表示形式的可增长队列。 如果未指定最大容量,则队列永远不会填满,因此永远不会阻塞。put

与 Java 中的其他集合类一样,这些同步队列可以保存任意类型的对象。 我们必须为队列中的消息选择或设计一种类型:我们将选择一个不可变的类型,因为我们使用消息传递的目标是避免共享内存的问题。 生产者和使用者将仅通过发送和接收消息进行通信,并且不会通过更改别名消息对象进行(错误)通信。正如我们在线程安全 ADT 上设计操作以防止争用条件并使客户端能够执行所需的原子操作一样,我们将设计具有相同要求的消息对象。

四、消息传递示例

您可以在 GitHub 上查看此示例的所有代码。 与讨论有关的部分摘录如下。

下面是一个表示冰箱的消息传递模块:

 
/**
 * A mutable type representing a refrigerator containing drinks.
 */
public class DrinksFridge {

    private int drinksInFridge;
    private final BlockingQueue<Integer> in;
    private final BlockingQueue<FridgeResult> out;

    // Abstraction function:
    //   AF(drinksInFridge, in, out) = a refrigerator containing `drinksInFridge` drinks
    //                                 that takes requests from `in` and sends replies to `out`
    // Rep invariant:
    //   drinksInFridge >= 0

    /**
     * Make a DrinksFridge that will listen for requests and generate replies.
     * 
     * @param requests queue to receive requests from
     * @param replies queue to send replies to
     */
    public DrinksFridge(BlockingQueue<Integer> requests, BlockingQueue<FridgeResult> replies) {
        this.drinksInFridge = 0;
        this.in = requests;
        this.out = replies;
        checkRep();
    }

    ...
}

该模块有一个方法,用于创建内部线程来为其输入队列上的请求提供服务:start

 
/**
 * Start handling drink requests.
 */
public void start() {
    new Thread(new Runnable() {
        public void run() {
            while (true) {
                try {
                    // block until a request arrives
                    int n = in.take();
                    FridgeResult result = handleDrinkRequest(n);
                    out.put(result);
                } catch (InterruptedException ie) {
                    ie.printStackTrace();
                }
            }
        }
    }).start();
}

发送到的传入消息是整数,表示要从冰箱中取出(或添加到)的饮料数量:DrinksFridge

/**
 * Take (or add) drinks from the fridge.
 * @param n if >= 0, removes up to n drinks from the fridge;
 *          if < 0, adds -n drinks to the fridge.
 * @return DrinkResult reporting how many drinks were actually added or removed
 *      and how many drinks are left in the fridge. 
 */
private FridgeResult handleDrinkRequest(int n) {
    // adjust request to reflect actual drinks available
    int change = Math.min(n, drinksInFridge);
    drinksInFridge -= change;
    checkRep();
    return new FridgeResult(change, drinksInFridge);
}

传出消息是以下实例:FridgeResult

/**
 * An immutable message describing the result of taking or adding drinks to a DrinksFridge.
 */
public class FridgeResult {
    private final int drinksTakenOrAdded;
    private final int drinksLeftInFridge;
    // Rep invariant: 
    //   TODO

    /**
     * Make a new result message.
     * @param drinksTakenOrAdded (precondition? TODO)
     * @param drinksLeftInFridge (precondition? TODO)
     */
    public FridgeResult(int drinksTakenOrAdded, int drinksLeftInFridge) {
        this.drinksTakenOrAdded = drinksTakenOrAdded;
        this.drinksLeftInFridge = drinksLeftInFridge;
    }

    // TODO: we will want more observers, but for now...

    @Override public String toString() {
        return (drinksTakenOrAdded >= 0 ? "you took " : "you put in ") 
                + Math.abs(drinksTakenOrAdded) + " drinks, fridge has " 
                + drinksLeftInFridge + " left";
    }
}

我们可能会添加其他观察器,以便客户端可以检索值。FridgeResult

最后,这是加载冰箱的主要方法:

public static void main(String[] args) {

    BlockingQueue<Integer> requests = new LinkedBlockingQueue<>();
    BlockingQueue<FridgeResult> replies = new LinkedBlockingQueue<>();

    // start an empty fridge
    DrinksFridge fridge = new DrinksFridge(0, requests, replies);
    fridge.start();

    try {
        // deliver some drinks to the fridge
        requests.put(-42);

        // maybe do something concurrently...

        // read the reply
        System.out.println(replies.take());
    } catch (InterruptedException ie) {
        ie.printStackTrace();
    }
    System.out.println("done");
    System.exit(0); // ends the program, including DrinksFridge
}

五、如何停止?

如果我们想关闭它,使其不再等待新的输入怎么办? 一种策略是毒丸:队列上的一条特殊消息,向该消息的使用者发出结束其工作的信号。DrinksFridge

要关闭冰箱,因为它的输入消息只是整数,我们将不得不选择一个神奇的毒药整数(也许没有人会要求 0 杯饮料......?不要使用幻数)或使用 null(不要使用 null)。 相反,我们可能会将请求队列上的元素类型更改为 ADT:

FridgeRequest = DrinkRequest(n:int) + StopRequest

操作:

drinksRequested : FridgeRequest → int
shouldStop : FridgeRequest → boolean

当我们想停止冰箱时,我们会排队 哪里返回 .FridgeRequestshouldStoptrue

例如,在 中填写以下练习中的空白:DrinksFridge.start()

public void run() {
    while (true) {
        try {
            // block until a request arrives
            FridgeRequest req = in.take();
            // see if we should stop
            if (▶▶A◀◀) { ▶▶B◀◀; }
            // compute the answer and send it back
            int n = ▶▶C◀◀;
            FridgeResult result = handleDrinkRequest(n);
            out.put(result);
        } catch (InterruptedException ie) {
            ie.printStackTrace();
        }
    }
}

也可以通过调用线程的 interrupt() 方法来向线程发出它应该停止工作的信号。 如果目标线程在等待时被阻塞,则它被阻塞的方法将引发中断异常。 这就是为什么我们几乎在调用阻塞方法时都必须尝试捕获该异常的原因。 如果未阻止目标线程,则将设置中断标志。 为了使用此方法停止线程,目标线程必须处理任何 s 并检查中断标志以查看它是否应停止工作。 例如:Interrupted­Exception

public void run() {
    // handle requests until we are interrupted
    while ( ! Thread.interrupted()) {
        try {
            // block until a request arrives
            int n = in.take();
            FridgeResult result = handleDrinkRequest(n);
            out.put(result);
        } catch (InterruptedException ie) {
            // stop
            break;
        }
    }
}

六、带有消息传递的线程安全参数

带有消息传递的线程安全参数可能依赖于:

  • 同步队列的现有线程安全数据类型。 这个队列绝对是共享的,绝对是可变的,所以我们必须确保它是安全的并发。

  • 多个线程可能同时访问的消息或数据的不可变性

  • 将数据限制在单个生产者/消费者线程中。 一个生产者或使用者使用的局部变量对其他线程不可见,这些线程仅使用队列中的消息相互通信。

  • 限制通过队列发送但一次只能由一个线程访问的可变消息或数据。 必须仔细阐述和执行这一论点。 假设一个线程有一些可变数据要发送到另一个线程。 如果第一个线程在将数据放入队列以传递到另一个线程后立即删除对数据的所有引用,就像烫手山芋一样,则一次只有一个线程可以访问这些数据,从而排除并发访问。

与同步相比,消息传递可以使并发系统中的每个模块更轻松地维护自己的线程安全不变性。 如果数据使用线程安全通信通道在模块之间传输,我们不必推理多个线程访问共享数据。

七、防范死锁产生

阻塞队列的阻塞行为对于编程来说非常方便,但阻塞也引入了死锁的可能性。 在死锁中,两个(或多个)并发模块都被阻塞,等待对方执行某些操作。 由于它们被阻止,因此没有模块能够使任何事情发生,并且它们都不会打破僵局。

一般来说,在多个并发模块相互通信的系统中,我们可以想象绘制一个图,其中图的节点是模块,如果模块 A 被阻塞等待模块 B 做某事,则从 A 到 B 有一个边缘。 如果在某个时间点,此图中存在一个循环,则系统将陷入僵局。 最简单的情况是双节点死锁,A → B 和 B → A,但更复杂的系统可能会遇到更大的死锁。

死锁在锁中比在消息传递中更常见 - 但是当消息传递队列的容量有限,并且被消息填满时,即使是消息传递系统也会遇到死锁。 处于死锁状态的消息传递系统似乎只是卡住了。

让我们看一个消息传递死锁的示例,使用我们到目前为止一直在使用的相同。 这一次,我们将使用具有固定容量的实现,而不是使用可以任意增长(仅受内存大小限制):DrinksFridgeLinkedBlockingQueuesArrayBlockingQueue

private static final int QUEUE_SIZE = 100;
...
    // make request and reply queues big enough to hold QUEUE_SIZE messages each
    BlockingQueue<Integer> requests = new ArrayBlockingQueue<>(QUEUE_SIZE);
    BlockingQueue<FridgeResult> replies = new ArrayBlockingQueue<>(QUEUE_SIZE);

出于性能原因,许多消息传递系统使用固定容量队列,因此这是一种常见情况。

最后,为了创建死锁所需的条件,客户端代码将发出请求,每个请求都要求喝一杯,然后再检查来自 的任何回复。 以下是完整的代码:NDrinksFridge

private static final int QUEUE_SIZE = 100;
private static final int N = 100;

/**  Send N thirsty people to the DrinksFridge. */
public static void main(String[] args) throws IOException {
    // make request and reply queues big enough to hold QUEUE_SIZE messages each
    BlockingQueue<Integer> requests = new ArrayBlockingQueue<>(QUEUE_SIZE);
    BlockingQueue<FridgeResult> replies = new ArrayBlockingQueue<>(QUEUE_SIZE);

    DrinksFridge fridge = new DrinksFridge(requests, replies);
    fridge.start();

    try {
        // put enough drinks in the fridge to start
        requests.put(-N);
        System.out.println(replies.take());

        // send the requests
        for (int x = 1; x <= N; ++x) {
            requests.put(1); // give me 1 drink!
            System.out.println("person #" + x + " is looking for a drink");
        }
        // collect the replies
        for (int x = 1; x <= N; ++x) {
            System.out.println("person #" + x + ": " + replies.take());
        }
    } catch (InterruptedException ie) {
        ie.printStackTrace();
    }
    System.out.println("done");
    System.exit(0); // ends the program, including DrinksFridge thread
}

事实证明,使用 =100 和 =100,上面的代码有效并且没有达到死锁。 但随着规模越来越大,我们的客户提出了许多请求,但没有阅读任何回复。 如果大于 ,则队列将填满未读答复。 然后阻止尝试在该队列中再回复一个,并停止调用队列。 客户端可以继续将更多请求放入队列中,但最多不得超过队列的大小。 如果其他请求的数量超过了该队列所能容纳的范围(即,当大于 2× 时),则客户端的调用也将阻塞。 现在我们有了致命的拥抱。 正在等待客户端读取一些回复并释放队列上的空间,但客户端正在等待接受一些请求并释放队列上的空间。 僵局。

NQUEUE_SIZENNQUEUE_SIZErepliesDrinksFridgeputtakerequestsrequestsNQUEUE_SIZErequests.put()DrinksFridgerepliesDrinksFridgerequests

防止死锁的建议:

解决死锁的一种解决方案是将系统设计为没有循环的可能性——这样,如果 A 正在等待 B,则不可能是 B 已经(或将要开始)等待 A。

死锁的另一种方法是超时。 如果一个模块被阻塞的时间过长(可能是100毫秒?或者10秒?如何解决?),那么你停止阻塞并抛出异常。 现在问题变成了:当抛出该异常时,您会怎么做?

如果一个模板被阻塞的时间过长,可能导致超时异常。解决这个问题可以包括使用线程池来管理并发、优化阻塞代码、增加等待超时时间、尝试重试等。如果抛出异常,处理方式却决于具体情况。通常要分析异常类型和上下文信息,采取适当的措施。例如记录日志、回滚事务、重新调度任务等。具体应该怎么处理需要开发人员随机应变。

八、总结

  • 消息传递系统不是与锁同步,而是在共享通信通道(例如流或队列)上同步。

  • 与阻塞队列通信的线程是在单个进程中传递消息的有用模式。

九、我的感受

这篇文章主要介绍了多线程编程中使用同步队列实现消息传递的方法,并提供了一个可变类型的示例来展示如何在同步队列上进行线程安全的操作。具体而言,文章阐述了以下几点:

1. 消息传递是多线程编程中线程通信的一种有效方式,在同步队列的基础上可以实现高效、安全的消息传递。
2. 同步队列是一种线程安全的队列,它提供了put和take等方法来实现线程间的同步和通信,同时支持阻塞等待。
3. 可变类型(mutable type)指的是可以被修改的值,如果多个线程同时对同一可变类型进行修改,可能会导致数据竞争(data race)等线程安全问题。在同步队列上进行线程安全的可变类型操作需要避免这些问题,例如使用互斥量(mutex)或其他同步机制来保证操作的原子性。

4.当我们需要在多个线程之间传递消息时,我们可以使用阻塞队列来实现。阻塞队列是一种现有的线程安全类型,它可以在进程内的线程之间实现消息传递。阻塞队列的一些操作是阻塞的,因为调用操作会阻止线程的进度,直到操作可以返回结果。

总之,这篇文章讨论了同步队列在多线程编程中的应用和线程安全问题,介绍了可变类型的概念并提供了一个示例,让读者了解如何使用同步队列进行线程安全的可变类型操作。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值