MIT 6.005 Reading 19: Concurrency 并发 [翻译]

了解目标

  • 消息传递和共享内存模型的并发性
  • 并发进程和线程以及时间切片
  • 竞争情况的危险

并发

并发Concurrency 意味着多个计算同时发生。无论我们喜欢与否,并发在现代编程中无处不在:

  • 网络中的多台计算机
  • 在一台计算机上运行多个应用
  • 计算机中的多个处理器(通常是单个芯片上的多个处理器内核)

实际上,并发性在现代编程中至关重要:

  • 网站必须处理多个并发用户。
  • 移动应用程序需要在服务器上进行一些处理(“在云端”)。
  • 图形用户界面总是需要一些不会打扰用户的后台工作。例如,Eclipse在您编辑Java代码时编译它们。

在未来,并发编程仍然很重要。处理器时钟速度不再增加,但我们每个新一代芯片都会有更多的内核。因此,未来为了使计算更快地运行,我们必须将计算分成并发部分。

两种并发编程模型

并发编程有两种常见的模型:共享内存和消息传递。

共享内存
共享内存。在并发的共享内存模型中,并发模块通过在内存中读取和写入共享对象来进行交互。

共享内存模型的示例:

  • A和B可能是同一台计算机中的两个处理器(或处理器核心),共享相同的物理内存。
  • A和B可能是在同一台计算机上运行的两个程序,它们共享一个公共文件系统,包含可以读写的文件。
  • A和B可能是同一Java程序中的两个线程(我们将解释下面的线程),共享相同的Java对象。

消息传递
消息传递。在消息传递模型中,并发模块通过通信通道相互发送消息进行交互。模块发送消息,并将每个模块的传入消息排队等待处理。例子包括:

  • A和B可能是网络中的两台计算机,通过网络连接进行通信。
  • A和B可以是Web浏览器和Web服务器 - A打开与B的连接并请求网页,B将网页数据发送回A.
  • A和B可能是即时消息客户端和服务器。
  • A和B可能是在同一台计算机上运行的两个程序,其输入和输出通过管道连接,如 ls | grep键入命令提示符。

进程,线程,时间切片

消息传递和共享内存模型是关于并发模块如何通信的。并发模块本身有两种不同的类型:进程和线程。

进程。进程是与同一台计算机上的其他进程隔离的正在运行的程序的实例。特别的,它有自己的机器内存的私有部分。

进程抽象是虚拟计算机。它使程序感觉它拥有整个机器本身 - 就像一台新的计算机已经创建,具有全新的内存,只是为了运行该程序。

就像通过网络连接的计算机一样,进程通常在它们之间不共享内存。进程根本无法访问其他进程的内存或对象。在大多数操作系统上都可以在进程之间共享内存,但比较麻烦。相比之下,新进程自动为消息传递做好准备,因为它是使用标准输入和输出流创建的,就比如您在Java中使用的System.out和System.in流。

线程。线程是正在运行的程序中的控制位置。将其视为正在运行的程序中的一个位置,以及导致该位置的方法调用堆栈(因此线程可以在到达返回语句时返回堆栈)。

就像进程代表虚拟计算机一样,线程抽象代表虚拟处理器。创建一个新线程模拟在该进程所代表的虚拟计算机内部创建一个新的处理器。这个新的虚拟处理器运行相同的程序,并与进程中的其他线程共享相同的内存。

线程自动为共享内存做好准备,因为线程共享进程中的所有内存。需要特别努力才能获得对单个线程私有的“线程本地”内存。还必须通过创建和使用队列数据结构来显式设置消息传递。未来的阅读中讨论如何做到这一点。

时间切片
如何在计算机中只有一个或两个处理器的许多并发线程? 当线程多于处理器时,通过时间切片模拟并发性,这意味着处理器在线程之间切换。 右图显示了如何在只有两个实际处理器的机器上对三个线程T1,T2和T3进行时间分片。 在该图中,时间向下进行,因此首先一个处理器运行线程T1而另一个处理器运行线程T2,然后第二个处理器切换到运行线程T3。 线程T2只是暂停,直到它在同一个处理器或另一个处理器上的下一个时间片。

在大多数系统上,时间切片以不可预测和不确定的方式发生,这意味着线程可能随时被暂停或恢复。


阅读材料 Java Tutorials
Processes and Threads
Defining and Starting a Thread


第二篇Java Tutorials阅读展示了两种创建线程的方法。

永远不要使用他们的第二种方式(子类化Thread)。
一定要用 实现Runnable接口并使用新的Thread(..)构造函数。
他们的示例声明了一个实现Runnable的命名类:

public class HelloRunnable implements Runnable {
    public void run() {
        System.out.println("Hello from a thread!");
    }
}
// ... in the main method:
new Thread(new HelloRunnable()).start();

一个非常常见的习惯是使用匿名Runnable启动一个线程,而无需命名这个类:

new Thread(new Runnable() {
    public void run() {
        System.out.println("Hello from a thread!");
    }
}).start();

额外阅读 using an anonymous Runnable to start a thread

共享内存案例

我们来看一个共享内存系统的例子。这个例子展现了并发编程的难点,因为它可能出现微妙的错误。

想象一下,银行有个使用共享内存模型的自动取款机,因此所有自动提款机都可以在内存中读取和写入相同的帐户对象。
Alt text

为了说明可能出现的问题,让我们将银行简化为一个帐户,将余额存储在余额变量中,两个操作存入和取出只需添加或删除一美元:

// suppose all the cash machines share a single bank account
private static int balance = 0;

private static void deposit() {
    balance = balance + 1;
}
private static void withdraw() {
    balance = balance - 1;
}

客户这样使用自动提款机进行交易:

deposit(); // put a dollar in
withdraw(); // take it back out

在这个简单的例子中,每笔交易只需一美元存款,然后是一美元提款,因此应该保持账户余额不变。 无时无刻,我们网络中的每台自动提款机都在处理一系列存款/取款交易。

// each ATM does a bunch of transactions that
// modify balance, but leave it unchanged afterward
private static void cashMachine() {
    for (int i = 0; i < TRANSACTIONS_PER_MACHINE; ++i) {
        deposit(); // put a dollar in
        withdraw(); // take it back out
    }
}

因此,在一天结束时,无论现金机运行了多少,或者我们处理了多少交易,我们都应该预计帐户余额仍为0。

但是,如果我们运行此代码,我们会经常发现当天结束时的余额不是0.如果多个cashMachine()调用同时运行 - 比如说,在同一台计算机上的不同处理器上 - 那么余额在一天结束时可能会不为0。 为什么呢?

交错 interleaving

这是可能发生情况。 假设两台取款机A和B同时处理存款。 以下是deposit()步骤通常分解为低级处理器指令的方法:

当A和B同时运行时,这些低级指令相互交错(某些甚至可能在某种意义上是同时的,但现在让我们担心交错):

他的余额现在是1 - A的美元丢失了! A和B都同时读取余额,计算单独的期末余额,然后竞相存储新的余额 - 未能将对方的存款考虑在内

(就是一个没加锁的情况)

竞争条件

这是竞争条件的一个例子。 竞争条件意味着程序的正确性(后置条件和不变量的满足)取决于并发计算A和B中事件的相对时间。当发生这种情况时,我们说“A正在与B竞争”。

一些事件的交错可能是好的,因为它们与单个非并发过程产生的一致,但是其他交错产生错误的答案 - 违反后置条件或不变量。

调整代码无济于事

所有这些版本的银行帐户代码都具有相同的竞争条件:

// version 1
private static void deposit()  { balance = balance + 1; }
private static void withdraw() { balance = balance - 1; }

// version 2
private static void deposit()  { balance += 1; }
private static void withdraw() { balance -= 1; }

// version 3
private static void deposit()  { ++balance; }
private static void withdraw() { --balance; }

你不能仅仅通过查看Java代码来判断处理器将如何执行它。 你无法分辨出不可分割的操作 - 原子操作 - 将是什么。 它不是原子的,因为它是Java的一行。 它仅仅因为余额标识符仅在行中出现一次而不会触及平衡一次。 Java编译器,实际上是处理器本身,不会对它将从代码生成的低级操作做出任何承诺。 实际上,典型的现代Java编译器为所有这三个版本生成完全相同的代码!

关键的一课是,你无法通过观察一个表达来判断它是否在竞争条件下是安全的。

重新排序

事实上,情况甚至更糟。 银行账户余额的竞争条件可以根据不同处理器上的顺序操作的不同交错来解释。 但实际上,当您使用多个变量和多个处理器时,您甚至无法依赖于以相同顺序出现的那些变量的更改。

这是一个例子。 请注意,它使用循环连续检查并发条件; 这被称为忙碌等待,这不是一个好的模式。 在这种情况下,代码也会被破坏:

private boolean ready = false;
private int answer = 0;

// computeAnswer runs in one thread
private void computeAnswer() {
    answer = 42;
    ready = true;
}

// useAnswer runs in a different thread
private void useAnswer() {
    while (!ready) {
        Thread.yield();
    }
    if (answer == 0) throw new RuntimeException("answer wasn't ready!");
}

我们有两种方法在不同的线程中运行。 computeAnswer进行长时间的计算,最后得出答案42,它放在答案变量中。然后它将ready变量设置为true,以便发信号通知另一个线程中运行的方法useAnswer,答案已准备好供它使用。查看代码,在设置ready之前设置answer,所以一旦useAnswer看到ready为true,那么它可以假设答案是42,这对我来说似乎是合理的吗?不是这样。

问题是现代编译器和处理器做了很多事情来快速编写代码。其中之一就是在更快的存储(处理器上的寄存器或缓存)中制作临时的变量副本,如answer和ready,并在最终将它们存储回内存中的官方位置之前暂时使用它们。回调的顺序可能与代码中操作变量的顺序不同。这是可能在幕后发生的事情(但用Java语法表达以表明它)。处理器有效地创建了两个临时变量tmpr和tmpa来操作ready和answer:

private void computeAnswer() {
    boolean tmpr = ready;
    int tmpa = answer;

    tmpa = 42;
    tmpr = true;

    ready = tmpr;
                   // <-- what happens if useAnswer() interleaves here?
                   // ready is set, but answer isn't.
    answer = tmpa;
}

消息传递示例

现在让我们看一下我们银行账户的消息传递方法示例。

现在不仅提款机是模块,帐户也是模块。 模块通过彼此发送消息进行交互。 传入请求被放入队列中,以便一次处理一个。 在等待其请求的答案时,发件人不会停止工作。 它处理来自自己队列的更多请求。 对其请求的回复最终会以另一条消息的形式返回。

不幸的是,消息传递并没有消除竞争条件的可能性。 假设每个帐户都支持get-balance和withdraw操作,以及相应的消息。 自动提款机A和B的两个用户都试图从同一帐户中提取一美元。 他们首先检查余额,以确保他们从不提取超过账户持有的余额,因为透支会触发大银行处罚:

get-balance
if balance >= 1 then withdraw 1

问题仍然是交错,但这次是发送到银行帐户的消息的交错,而不是A和B执行的指令。如果帐户以一美元开头,那么消息的交错会欺骗A和B 以为他们都可以提取一美元,从而透支账户?

这里的教训是,您需要仔细选择消息传递模型的操作。 withdraw-if-sufficient-funds将是一个更好的操作,而不仅仅是withdraw。

并发很难测试和调试

但目前为止你应该了解到并发有多么棘手。使用测试发现竞争条件非常困难。即使测试发现了一个错误,也可能很难定位到产生它的程序位置。

并发错误表现出非常差的可重复性。很难让它们以同样的方式发生两次。指令或消息的交织取决于受环境强烈影响的事件的相对时间。延迟可能是由其他正在运行的程序,其他网络流量,操作系统调度决策,处理器时钟速度的变化等引起的。每次运行包含竞争条件的程序时,您可能会得到不同的结果。

这些类型的bug是heisenbugs,它们是不确定的并且难以复制,而不是会反复出现的bohrbug。顺序编程中几乎所有的错误都是bohrbug

当您尝试使用println或调试器查看它时,heisenbugs甚至可能会消失!原因是打印和调试比其他操作慢得多,通常慢100-1000倍,它们会显着改变操作的时间和交错。所以在cashMachine()中插入一个简单的print语句:

private static void cashMachine() {
    for (int i = 0; i < TRANSACTIONS_PER_MACHINE; ++i) {
        deposit(); // put a dollar in
        withdraw(); // take it back out
        System.out.println(balance); // makes the bug disappear!
    }
}

…突然间,余额总是很理想地变为0,错误似乎消失了。 但它只是掩盖了,而不是真正的固定。 程序中其他位置的时间变化可能会突然使错误恢复。

并发很难做到正确。 这篇阅读的部分内容是吓唬你。 在接下来的几个读物中,我们将看到设计并发程序的原则性方法,以便它们更安全地避免这些类型的错误。

猜你喜欢

转载自blog.csdn.net/smmyy022/article/details/82053807