ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

软件构造——关于并发

2021-05-16 18:29:18  阅读:208  来源: 互联网

标签:void 构造 并发 private 线程 处理器 软件 balance


 

第十九节  并发

 

目标

  • 消息传递和共享内存的并发模型
  • 并发进程和线程,以及时间分片
  • 比赛条件的危险

    并发

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

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

    实际上,并发在现代编程中必不可少:

    • 网站必须同时处理多个用户。
    • 移动应用程序需要在服务器上(“在云端中”)进行一些处理。
    • 图形用户界面几乎总是需要不打断用户的后台工作。例如,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.outSystem.in流)创建的。

    每当您启动Java程序时-实际上,无论何时您在计算机上启动任何程序-它都会启动一个全新的过程来包含正在运行的程序。

    线程 .  线程是正在运行的程序内部的控制源。可以将其视为正在运行的程序中的一个位置,再加上导致该位置的方法调用堆栈(以便线程在到达return语句时可以返回堆栈)。

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

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

    每当您运行Java程序时,该程序就会从一个线程开始,这main()是第一步。该线程称为主线程

    时间分片

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

    在大多数系统上,时间分片是无法确定和不确定地发生的,这意味着线程可以随时暂停或恢复。

    在Java中启动线程

    您可以通过创建实例Thread并将其告知来启动新线程start()。您可以通过创建一个实现类来为新线程提供运行代码Runnable。新线程将要做的第一件事是调用run()此类中的方法。例如:

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

    但是一个很常见的用语开始有一个线程匿名的 Runnable,这样就在所有类中无需命名HelloRunnable

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

    接下来的两节将讨论匿名类的概念,因为匿名类在Java中已广泛使用,而不仅仅是线程。

    匿名类

    通常,当我们实现接口时,我们通过声明一个类来实现。例如,给定ComparatorJava API中的接口:

    /** A comparison function that imposes a total ordering on some objects.
     *  ... */
    public interface Comparator<T> {
        /** Compares its two arguments for order.
         *  ...
         *  @return a negative integer, zero, or a positive integer if the first
         *          argument is less than, equal to, or greater than the second */
        public int compare(T o1, T o2);
    }

    我们可以声明:

    /** Orders Strings by length (shorter first) and then lexicographically. */
    public class StringLengthComparator implements Comparator<String> {
        @Override public int compare(String s1, String s2) {
            if (s1.length() == s2.length()) {
                return s1.compareTo(s2);
            }
            return s1.length() - s2.length();
        }
    }

    其目的之一Comparator是进行分类。ASortedSet将其项目保持在总顺序中。

    如果不使用Comparator,则SortedSet实现将使用compareTo集合中的对象提供的方法:

    SortedSet<String> strings = new TreeSet<>();
    strings.addAll(Arrays.asList("yolanda", "zach", "alice", "bob"));
    // strings is { "alice", "bob", "yolanda", "zach" }

    带有Comparator

    // uses StringLengthComparator declared above
    Comparator<String> compareByLength = new StringLengthComparator();
    SortedSet<String> strings = new TreeSet<>(compareByLength);
    strings.addAll(Arrays.asList("yolanda", "zach", "alice", "bob"));
    // strings is { "bob", "zach", "alice", "yolanda" }

    如果仅打算在此位置使用此比较器,则我们已经知道如何消除该变量:

    // uses StringLengthComparator declared above
    SortedSet<String> strings = new TreeSet<>(new StringLengthComparator());
    strings.addAll(Arrays.asList("yolanda", "zach", "alice", "bob"));
    // strings is { "bob", "zach", "alice", "yolanda" }

    一个匿名类声明未命名的类,它实现一个接口,并立即创建类的唯一实例。比较上面的代码:

    // no StringLengthComparator class!
    SortedSet<String> strings = new TreeSet<>(new Comparator<String>() {
        @Override public int compare(String s1, String s2) {
            if (s1.length() == s2.length()) {
                return s1.compareTo(s2);
            }
            return s1.length() - s2.length();
        }
    });
    strings.addAll(Arrays.asList("yolanda", "zach", "alice", "bob"));
    // strings is { "bob", "zach", "alice", "yolanda" }

    匿名类比命名类的优点:

    • 如果仅在这段代码中使用比较器,则通过使用匿名类来减小其范围。使用命名类,任何其他代码都可以开始使用,并取决于StringLengthComparator

    • 读者不再需要在其他地方搜索比较器的详细信息,一切就在这里。

    缺点:

    • 如果我们不止一次需要同一个比较器,我们可能会试图复制和粘贴匿名类。命名的类是DRY。

    • 如果匿名类的实现很长,则会中断周围的代码,使其更难理解。命名的类作为模块化的部分分离出来。

    因此,匿名类对于一种方法的短期一次性实现很有用。

    使用匿名Runnable启动线程

    Runnables我们使用它来创建新的线程往往完全符合这些标准。

    这是我们上面使用的示例:

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

    与其(1)声明一个Runnablerun方法调用处实现的类System.out.println,(2)创建该类的实例以及(3)将该实例传递给Thread构造函数,不如使用匿名方法一次完成所有三个步骤Runnable

    如果您觉得自己很聪明,可以使用Java的lambda表达式更进一步:

    new Thread(() -> System.out.println("Hello from a thread!")).start();

    是否更容易理解还是一个争论。 Runnable而且run根本不会出现,因此您当然必须要做更多的研究,才能在首次遇到该结构时理解它。

     

    共享内存示例

    让我们看一个共享内存系统的例子。这个例子的目的是说明并发编程是困难的,因为它可能有一些细微的错误。

    银行帐户的共享内存模型

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

     

    为了说明可能出现的错误,让我们简化了银行下降到一个帐户,与存储在一美元平衡balance变量,两个操作depositwithdraw简单地添加或删除美元:

    // 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

    每笔交易只是一笔1美元的存款,然后是1美元的提款,因此应该保留帐户中的余额不变。每天,我们网络中的每台提款机都在处理一系列的充值/提款交易:

    // 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()同时运行多个调用(例如,在同一台计算机上的不同处理器上),则balance可能不为零。在一天结束时。为什么不是呢?

    交织

    这是可能发生的一件事。假设两个自动取款机A和B都同时在存款。以下是将deposit()步骤通常分解为低级处理器指令的方式:

    取得balance(balance= 0)
    加1
    写回结果(balance= 1)

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

    一种
    获得balance(balance= 0) 
    加1 
    回写result(balance= 1) 
     B得到balance(balance = 1)
     B加1
     B写回result(balance= 2)

    这种交织很好–我们最终得到balance2,因此A和B都成功投入了1美元。但是,如果交错看起来像这样:

    一种
    获得balance(balance= 0) 
     B得到balance(balance = 0)
    加1 
     B加1
    回写result(balance= 1) 
     B写回result(balance= 1)

    现在余额为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的一行。它不会balance仅接触一次,因为balance标识符仅在行中出现一次。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,并将其放入answer变量中。然后,它将ready变量设置为true,以便向在另一个线程中运行的方法发出信号useAnswer,表明可以使用该答案了。看代码,answer设置之前ready设定,所以一旦useAnswer看到ready为真,那么它似乎是合理的,它可以假设answer将是42吧?不是这样

    问题在于,现代的编译器和处理器会做很多事情来使代码更快。这些事情之一是在answerready更快的存储(处理器中的寄存器或缓存)中创建变量的临时副本,并在最终将它们存储回其官方位置之前临时使用它们。回存的顺序可能与代码中操作变量的顺序不同。这可能是在幕后进行的(但用Java语法表示)。处理器有效地创建了两个临时变量tmprtmpa,以操作字段readyanswer

    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上的两个用户都试图从同一个帐户中提取1美元。他们首先检查余额,以确保他们从未提取超过帐户余额的款项,因为透支会导致巨额的银行罚款:

    get-balance
    if balance >= 1 then withdraw 1

    问题又是交织,但是这次是交织发送到银行帐户的消息,而不是交由A和B执行的指令。如果帐户以美元开头,那么什么交织的消息会使A和B陷入愚弄认为他们都可以提取1美元,从而提取帐户?

    这里的一个教训是您需要仔细选择消息传递模型的操作。

    并发很难测试和调试

    如果我们没有说服您并发性很棘手,那么这就是最糟糕的情况。使用测试很难发现比赛条件。即使测试发现了错误,也可能很难将其本地化到导致该错误的程序部分。

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

    这些bug是heisenbug,它们是不确定的且难以复制,而bohrbug则在您每次查看时都会反复出现。顺序编程中的几乎所有bug都是bohrbug。

    当您尝试使用printlndebugger!来查看heisenbug时,它甚至可能消失。原因是打印和调试比其他操作要慢得多,通常要慢100-1000倍,以至于它们极大地改变了操作的时间和交错。因此,将一个简单的print语句插入cashMachine()

    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!
        }
    }

    …突然之间,balance始终为0(根据需要),并且该错误似乎消失了。但这只是被掩盖的,并不是真正固定的。在程序中其他地方更改时间可能会突然使该错误再次出现。

    并发很难实现。阅读的部分内容是使您感到有些恐惧。在接下来的几篇阅读文章中,我们将介绍设计并发程序的原则方法,以使它们更安全地避免此类错误。

    概括

    • 并发:多个计算同时运行
    • 共享内存和消息传递范例
    • 进程和线程
      • 流程就像一台虚拟计算机;线程就像一个虚拟处理器
    • 比赛条件
      • 结果的正确性(后置条件和不变式)何时取决于事件的相对时间

    这些想法大多以不良的方式与我们的优质软件的三个关键属性相关联。并发是必需的,但会导致严重的问题,以确保正确性。在接下来的几读中,我们将致力于解决这些问题。

    • 安全的错误,并发错误是一些最难发现和修复的错误,需要仔细设计才能避免。

    • 容易明白,对于程序员而言,预测并发代码如何与其他并发代码交织是非常困难的。最好以这样一种方式来设计代码,即程序员根本不必考虑交错。

    • 准备好进行更改,这里没有相关联性 

标签:void,构造,并发,private,线程,处理器,软件,balance
来源: https://blog.csdn.net/m625829670/article/details/116898159

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有