美文网首页
二. 并发和多线程基础理论

二. 并发和多线程基础理论

作者: shallowinggg | 来源:发表于2019-02-27 22:32 被阅读0次

    多线程的好处

    尽管多线程有着许多挑战,但是多线程仍然在使用的原因在于多线程有着诸多好处。其中一些好处如下:

    • 更好的资源利用
    • 某些情况下更简单的程序设计
    • 响应性更好的程序

    1.更好的资源利用

    想象一个应用从本地文件系统中读取和执行文件。我们假设从磁盘中读取一个文件需要5s,执行它需要2s。执行两个文件将花费

        5秒读取文件A
        2秒执行文件A
        5秒读取文件B
        2秒执行文件B
        ----------------------
        总共14秒
    

    当从磁盘中读取一个文件时,大部分的CPU时间用来等待从磁盘读取数据。在那段时间,CPU是非常空闲的,它可以用来做一些其他事。通过改变操作的顺序,CPU可以得到更好的利用,看下这个顺序:

      5秒读取文件A
      5秒读取文件B + 2秒执行文件A
      2秒执行文件B
      ----------------------
      总共12秒
    

    CPU等待第一个文件被读取完,然后它开始读取第二个文件。在第二个文件被读取的同时,CPU执行第一个文件。记住,当等待文件从磁盘中读取时,CPU是非常空闲的。

    一般情况下,CPU可以在等待IO时做一些其他事。IO不只是磁盘IO,网络IO也一样,或者是机器上一个用户的输入。网络和磁盘IO比起CPU和内存IO来说是非常慢的。

    2.更简单的程序设计

    如果你要在一个单线程应用中编写上面顺序的文件读取和执行过程,你需要记录每个文件的读取和执行状态。作为代替,你可以启动两个线程,每个线程只读取和执行一个文件。当等待磁盘读取文件时线程会被阻塞,在等待时,另一个线程可以使用CPU来执行它们已经读取的文件。这将导致磁盘时刻保持忙碌,读取各种文件到内存中。这也导致磁盘和CPU的利用率更高。它也很容易编程,因为每个线程只和一个文件有关联。

    3.响应性更好的程序

    将单线程应用转变为多线程应用的另一个目的是获取更好的响应。想象一个服务器应用,它监听一些端口等待请求到来。当一个请求到达,它将处理请求然后继续监听。这个服务的循环简单表述如下:

    while(服务器是活动的) {
        监听请求
        执行请求
    }
    

    如果一个请求要花费很多时间执行,在那期间将没有新的客户端可以发送请求给服务器,只有当服务器处于监听状态时才能够接受请求。

    一个代替的设计就是监听线程将请求提交给工作线程来执行,然后立刻返回继续监听。工作线程将会执行请求然后发送一个回复给客户端。这个设计如下:

    while(服务器是活动的) {
        监听请求
        将请求提交给工作线程
    }
    

    通过这种方式服务器将会很快回到监听状态,因此更多客户端可以发送请求给服务器。这个服务器的响应性会变得更好。

    对于桌面应用来说是一样的。如果你点击一个按钮启动一个长任务,然后线程执行这个任务更新窗口,按钮等,当任务执行时这个应用将会出现无响应的情况。作为代替,这个任务可以提交给一个工作线程执行。当工作线程在执行任务时,窗口线程仍然能够响应其他用户请求。当工作线程完成任务,它将通知窗口线程,接下来窗口线程可以使用此任务的结果来更新应用窗口。使用工作线程设计的程序对用户来说显得响应性更好。

    多线程的代价

    从单线程到多线程应用并不只带来好处,它也有一些代价。不要只因为你能做到就在应用中使用多线程。你应当知道这样做之后的好处是否大于它所带来的开销。当存在疑惑时,尝试测试应用的性能或响应能力而不是仅仅做猜想。

    1.更复杂的设计

    虽然某些情况下多线程应用比单线程应用更简单,但是在其他情况下会更复杂。多线程执行的代码获取共享数据需要特殊的关注,线程间的交互也不永远是简单的。不正确的线程同步引发的问题也更加难以检测,再次出现以及处理。

    下面展示一段未进行线程同步的简单代码:

    import org.junit.Test;
    
    import java.util.concurrent.atomic.AtomicInteger;
    
    import static java.lang.Thread.sleep;
    
    public class MultiThreadsCostTest {
        //非同步计数器
        private int count;
        //同步计数器
        private AtomicInteger atomicCount = new AtomicInteger();
        //累加次数
        private static final int NUM = 10000;
    
        @Test
        public void test() throws Exception {
            //启动四个线程进行累加操作
            for(int i = 0; i < 4; ++i) {
                new Thread(() -> {
                    for(int j = 0; j < NUM; ++j ) {
                        count++;
                    }
                }).start();
                new Thread(() -> {
                    for(int j = 0; j < NUM; ++j ) {
                        atomicCount.getAndIncrement();
                    }
                }).start();
            }
    
            //等待累加完成
            sleep(3000);
            System.out.println("count: " + count);
            System.out.println("atomicCount: " + atomicCount.get());
        }
    }
    

    运行两次,结果如下:

    从图中我们可以看出,未进行同步的累加产生的结果时错误的,并且每次结果很难会再次相同(可以进行更多次测试论证),所以不正确的线程同步引发的问题也更加难以检测,再次出现以及处理。

    2.频繁的上下文切换

    即使是单核处理器也支持多线程执行代码,CPU给每个线程分配时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停的切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒。

    当一个CPU从执行一个线程切换到另一个线程时,这个CPU需要保存当前线程的本地数据,程序指针等,并且加载下一个要执行的线程的数据和程序指针等。这个切换过程被称作为“上下文切换”,CPU从执行一个线程的上下文到执行另一个线程的上下文。

    就像是我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记下这本书读到了多少页多少行,等查完单词后,能够继续读这本书。这样的切换是会影响读书效率的,同时上下文切换也会影响多线程的执行速度。
    上下文切换并不是廉价的,你不会想要在多个线程中频繁切换。

    你可以在维基百科中获取更多关于上下文切换的知识:
    http://en.wikipedia.org/wiki/Context_switch 英文版
    https://zh.wikipedia.org/wiki/%E4%B8%8A%E4%B8%8B%E6%96%87%E4%BA%A4%E6%8F%9B 中文版

    多线程一定快么?
    下面演示串行和并发执行累加操作的时间,请分析:下面的并发执行一定比串行执行快么?

    public class ConcurrencyTest {
       private static final long COUNT = 10000L;
    
       public static void main(String[] args) throws InterruptedException {
           serial();
           concurrency();
       }
    
       private static void concurrency() throws InterruptedException {
           long start = System.currentTimeMillis();
           Thread thread = new Thread(() -> {
               int a = 0;
               for(long i = 0; i < COUNT; ++i) {
                   a += 5;
               }
           });
           thread.start();
           int b = 0;
           for(long i = 0; i < COUNT; ++i) {
               b--;
           }
           thread.join();
           long time = System.currentTimeMillis() - start;
           System.out.println("concurrency: " + time + "ms, b=" + b);
       }
    
       private static void serial() {
           long start = System.currentTimeMillis();
           int a = 0;
           for(long i = 0; i < COUNT; ++i) {
               a += 5;
           }
           int b = 0;
           for(long i = 0; i < COUNT; ++i) {
               b--;
           }
           long time = System.currentTimeMillis() - start;
           System.out.println("serial: " + time + "ms, b=" + b + ", a=" + a);
       }
    }
    
    循环次数 串行执行耗时/ms 并发执行耗时/ms 并发比串行快多少
    1w 0 6
    10w 2 7
    100w 5 8 差不多
    1000w 19 11
    1亿 111 74

    从上表中我们可以看出并发执行并不是一定比串行执行快,因为线程有创建和上下文切换的开销。

    3.更多的资源开销

    一个线程为了运行需要从计算机中获取一些资源。除了CPU时间,一个线程还需要一些内存空间来保存它的本地堆栈。它可能还会占据一些在操作系统内部管理线程的资源。尝试写一个创建100个只等待不做事的线程的程序,然后查看这个应用运行时占用了多少内存。

    import org.junit.Test;
    
    import static java.lang.Thread.sleep;
    
    public class Test1 {
        @Test
        public void threadMemoryTest() throws Exception {
            for(int i = 0; i < 1000; ++i) {
                new Thread(() -> {
                    try {
                        //only wait
                        sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }).start();
            }
    
            sleep(4000);
            System.gc();
        }
    }
    

    加入-verbose:gc 虚拟机参数,观测结果:

    [0.033s][info][gc] Using G1[4.591s][info][gc] GC(0) Pause Full (System.gc()) 7M->2M(14M) 24.702ms
    

    第一个为在堆中占据的内存,第二个是任务管理器中此java应用占据的内存。可以自己改变创建线程的数量观测。

    并发模型

    并发系统可以使用不同的并发模型来实现。一个并发模型 指定了系统中的线程如何协作来完成它们的工作。不同的并发模型使用不同的方法划分工作,然后线程间可以以不同的方式来通信和协作。这个并发模型教程将会深入现在最流行的在使用中的并发模型(2015)。

    并发模型和分布式系统的相似点

    本文中描述的并发模型和分布式系统使用的各种架构相似。在一个并发系统中不同的线程彼此通信,在一个分布式系统中不同的进程(可能在不同的计算机中)彼此通信。线程和进程在本质上非常相似,这也是为什么各种不同的并发模型经常看起来和各种不同的分布式系统架构差不多。

    当然分布式系统拥有其他的挑战,例如网络可能出问题,或者远程计算机、进程停止运行等。但是一个运行在大的服务器上的并发系统也可能会遇到相似的问题,例如CPU坏了,网卡问题,磁盘问题等。这种问题出现的可能性很低,但是在理论上仍然可能出现。

    因为并发模型和分布式系统很相似,它们经常相互借鉴一些想法。例如,在工作者(线程)中分配工作的模型和分布式系统中的负载均衡 load balancing in distributed systems模型相似。错误处理技术如日志,故障切换(fail-over),作业幂等性(idempotency of jobs)等也是相似的。

    并行工作者 parallel worker

    第一个并发模型我称它为并行工作者 (parallel worker)模型,作业被分配给不同的工作者。这里是描述并行工作者并发模型的图:

    在并行工作者并发模型中,一个委托者将到来的工作分配给不同的工作者。每个工作者完成整个工作过程。工作者们并行工作,在不同的线程上运行,也可能在不同的CPU上。

    如果并行工作者并发模型在一个汽车工厂中实现,那么就是一个工作者生产一辆车。工作者将会生产指定的车辆,并且从头到尾完成所有生产过程。

    并行工作者并发模型一般在Java应用中大量使用(虽然正在改变中)。许多在J.U.C java.util.concurrent Java package 中的并发组件都使用这个模型来设计。你也可以看到在Java企业版本应用服务中这个模型的使用。

    并行工作者优点

    并行工作者并发模型的优点是它很容易理解。为了增加应用的并行性,你只需要增加更多的工作者。

    例如,如果你正在实现一个网络爬虫,你可以使用不同数量的工作者爬取某个数量的页面然后查看使用哪个数量的工作者可以获取最短的时间(意味着最高的性能)。因为网络爬虫是一个IO密集型工作,你可能会在你的计算机上最终采用每个CPU/核心上只有很少几个线程的模式。如果每个CPU上只有一个线程就太少了,因为它将会有很多空闲时间等待数据下载。

    并行工作者缺点

    并行工作者并发模型在简单的表面下潜藏了一些缺点。我将在下面的部分说明最明显的缺点。

    1. 共享状态会变复杂

    事实上并行工作者并发模型不像上面说的那么简单。工作者经常需要共享某些类型的数据,这些数据或者在内存中或者在共享的数据库中。下面的图展示了这将如何使并行工作者并发模型复杂化:

    在通信机制中某些共享状态(shared state)是任务队列,但是其他种类的共享状态如企业数据,数据缓存,数据库连接池等。

    只要并发工作者并发模型中混杂了共享状态,它将开始变得复杂。线程需要以某种方式获取共享状态然后确保共享状态被修改后对其他线程可见(送入主内存中而不是只保存在修改线程的CPU缓存中)。线程需要避免竞争条件 race conditions, 死锁 deadlock 和许多其他共享状态引发的并发问题。

    另外,当访问并发数据结构时,多个线程会彼此等待,导致并行状态会部分丢失,因为这部分线程将暂停执行。许多并发数据结构是阻塞的,这意味只有一个或有限数量的线程可以访问它们,这会导致线程在这些共享数据结构上竞争。高竞争将会在根本上影响访问共享数据结构的代码执行的序列程度(degree of serialization of execution),因为很多线程因无法访问到共享数据而被暂停。

    现代的非阻塞并发算法 non-blocking concurrency algorithms 可以降低竞争并且提高性能,但是非阻塞算法很难实现。

    持久化数据结构(Persistent data structures)是另一种代替方法。一个持久化数据结构在修改时总是会保存它自己先前的版本。因此,如果多个线程引用相同的持久化数据结构然后一个线程修改它,修改线程获得一个指向新的结构的引用。所有的其他线程依然指向旧的仍然未变的结构,因此保持了一致性。Scala编程语言拥有几个持久化的数据结构。

    虽然持久化数据结构是解决共享数据结构的并发修改问题的一个优雅的方法,但是持久化数据结构往往表现得并不那么好。

    例如,一个持久化 list 在它的头部增加所有的新元素,然后返回一个新增加元素的引用。所有其他的线程仍然持有一个指向未修改列表的第一个元素的引用,对于这些线程来说列表看起来并未改变,它们不会看到新增加的元素。

    这样一个持久化列表被实现为一个链表,不幸的是链表在现代硬件上表现得并不好。在列表上的每个元素都是一个不同的对象,这些对象会被分散到计算机内存的各个地方。现代CPU在顺序获取数据上更快,所以在现代硬件上你使用数组实现列表会得到很大的性能提升(参考LinkedList与ArrayList)。一个数组顺序存储数据。CPU缓存可以同时加载很大的数组到到缓存中,一旦加载完可以让CPU直接从缓存中获取数据。这对于数据分布在RAM各个地方的链表来说是不可能的。

    2. 无状态工作者 Stateless Workers

    共享状态可以被系统中的其他线程修改,因此工作者必须在每次需要它的时候重新读取共享状态,来确保自己在最新的拷贝副本上工作。这是正确的,不管共享状态保存在内存中还是在数据库中。一个工作者不会在自己内部保存状态(但是在每次需要的时候都重读它)被称作是无状态的 (stateless)。

    每当需要的时候重读数据会使程序变慢,尤其是状态存储在外部的数据库中时。

    3. 任务顺序是不确定的

    另一个并行工作者模型的缺点是任务执行顺序是不确定的,没有办法保证什么任务先被执行或后被执行。任务A可能在任务B前先提交给工作者,但是任务B可能会在任务A前执行。

        @Test
        public void test2() throws Exception {
            final int num = 10;
            Thread[] threads = new Thread[num];
    
            // 创建10个线程,只打印自己的名字
            for(int i = 0; i < num; ++i) {
                threads[i] = new Thread(() -> System.out.println(Thread.currentThread().getName()));
            }
    
            for(int i = 0; i < num; ++i) {
                threads[i].start();
            }
    
            sleep(3000);
        }
    

    上面代码启动了10个线程,分别输出自己的名字。将它们保存到数组中,然后按顺序启动,结果如下:

    可见即使线程按一种顺序启动,执行的顺序也是不确定的,不一定和启动的顺序相同。

    并行工作者模型不确定的特性使得很难在某个点及时推导系统的状态,它也很难去保证一个任务在另一个任务前发生(不是不可能)。

    下面演示一种保证任务发生顺序的例子:

    public class Join {
        public static void main(String[] args) throws Exception {
            Thread previous = Thread.currentThread();
            for(int i = 0; i < 10; ++i) {
                Thread thread = new Thread(new Domino(previous), String.valueOf(i));
                thread.start();
                previous = thread;
            }
            sleep(5000);
            System.out.println(Thread.currentThread().getName() + " terminate.");
        }
    
        private static class Domino implements Runnable {
            private Thread thread;
            Domino(Thread thread) {
                this.thread = thread;
            }
    
            @Override
            public void run() {
                try {
                    thread.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " terminate.");
            }
        }
    }
    

    运行结果如下:

    note:这些例子仅用来证明上面的观点,线程如果使用将在后面部分说明。

    流水线 Assembly Line

    第二个并发模型我称它为流水线 (assembly line)并发模型。我选择这个名字只是为了和前面的"并行工作者"意义兼容。其他的开发者使用其他的名字(例如 响应式系统,或者事件驱动系统),这依赖于不同的平台/社区。这是一张描述流水线并发模型的图:

    在流水线并发模型中工作者被组织的像是在工厂流水线上的工人,每个工作者只会完成所有工作的一部分,当工作的某个部分被当前工作者完成后就被推向下一个工作者完成后面的部分。

    每个工作者在它自己的线程上运行,并且不和其他的工作者共享状态。这也有时作为一个非共享 (shared nothing)的并发模型被提及。

    使用流水线并发模型的系统经常使用非阻塞IO来设计。非阻塞IO意味着当一个工作者启动一个IO操作(例如读取一个文件或从网络连接中读取数据)后,工作者不会等待IO调用完成。IO操作是缓慢的,所以等待IO操作完成会浪费CPU时间。CPU可以同时做一些其他的事情。当一个IO操作完成,IO操作的结果(例如数据读取或数据状态写入)被提交给另一个工作者做后续的事情。

    使用非阻塞IO,IO操作决定了工作者的边界。一个工作者尽量做它可以做的事直到它需要启动一个IO操作,然后它放弃控制这个任务。当IO操作完成后,在流水线上的下一个工作者继续在这个任务上工作,直到它也需要启动一个IO操作。

    The assembly line concurrency model with non-blocking IO operations marking the boundaries between worker responsibility.

    事实上,作业可能不止在一个流水线上流动。因为大多数系统可以执行不止一个作业,因此作业会根据需要完成的工作在工作人员间流动,事实上也有多个不同的虚拟流水线同时运作。下面是作业如何在流水线系统上流动的:

    作业为了并发执行甚至可以被提交给不止一个工作者。例如,一个作业可以同时被提交给一个作业执行器和一个作业日志记录器。这个图描述了三个流水线如何将他们的工作提交给一个相同的工作者(在中间流水线上的最后一个工作者)来完成:

    流水线还可以变得比这个更加复杂。

    响应式,事件驱动系统

    使用流水线并发模型的系统有些也会被称作响应式系统,或者事件驱动系统。这个系统的工作者响应发生在系统中的事件,或者接受从外部世界或其他工作者提交的事件。例如一个HTTP请求,或者某个文件已经加载到内存的信号等。

    在撰写本文的时候(2015),有许多有趣的响应式/事件驱动平台可获得,并且在未来会有更多平台出现。一些最知名的例如:

    • Vert.x
    • Akka
    • Node.JS (JavaScript)

    个人来说,我觉得Vert.x是最有趣的(尤其对一个像我一样的Java/JVM守旧者)。

    Actors vs. Channels

    Actors和channels是两个和流水线模型相似的例子。

    在actor模型中每个工作者被称作一个actor。Actors之间可以彼此直接发送消,。消息被发送并且异步执行。Actors可以被用作实现一个或多个作业执行流水线,像之前描述的那样。这里是一个描述actor模型的图:

    在channel [管道]模型中,工作者不能彼此直接通信。代替的是它们在不同的管道中发布它们的消息(活动)。其他工作者可以监听这些管道上的消息,这不需要发送者知道谁正在监听。这是一个描述channel模型的图:

    在写作的时候,channel模型对我来说更加灵活。一个工作者不需要知道哪个工作者在之后执行作业,它只需要知道在哪个管道上提交作业(或者发送消息等)。管道上的监听者工作与否不会影响工作者写入信息到管道中,这允许工作者间某种程度上的低耦合。

    流水线优点

    流水线并发模型比起并行工作者模型有几个优点。我将在下面的部分谈论几个最大的优点。

    1. 不共享状态 No Shared State

    工作者之间不共享状态的事实意味它们可以在实现时,无需考虑所有由并发访问状态引发的并发问题,这将使工作者很容易被实现。你可以实现一个工作者,只要它是执行那个工作的唯一的工作者 -- 根本上是一个单线程实现。

    2. 有状态的工作者 Stateful Workers

    因为工作者知道没有其他线程可以修改它们的数据,所以工作者是有状态的。使用有状态这个单词,我的意思是它们可以在内部保存它们需要在内存中操作的数据,只需将改变写回外存储系统。一个有状态的工作者因此比无状态工作者更快。

    3. 更好的硬件适应性 Better Hardware Conformity

    单线程代码有着更符合底层硬件工作的优势。首先,当你知道代码在单线程模式下被执行,你可以创建更多这种情况下最有效的数据结构和算法。

    其次,单线程有状态工作者可以像上面提到的那样在内存中缓存数据。当数据缓存在内存中,它也有更大的几率被缓存到执行线程的CPU缓存中,这使得获得数据更加快速。

    当代码以这种方式编写时,它能够从底层硬件工作中获取好处,所以我将它称为硬件适应 (hardware conformity)。一些开发者称它为机械和谐 (mechanical sympathy)。我更喜欢硬件适应这个词,因为计算机只有很少的机械部件,并且"sympathy"这个单词在本文中被使用作为"更好匹配"的象征,我相信"conform"这个词可以表达的更好。不管怎么说,这是细枝末节的东西。使用哪个单词全凭你喜好。

    4. 作业顺序的可能的

    使用流水线并发模型来保证作业顺序,并以此来实现并发系统是可能的。作业顺序保证使它更容易去及时表达某个点上的系统状态。更进一步来说,你可以把将会到来的作业写入日志中。这个日志可以被使用来重建系统状态,用来防止系统的崩溃。作业以某个顺序写入到日志中,并且这个顺序成为作业次序的保证。

    实现一个确定的作业次序不是简单的,但是经常是必要的。如果你可以做到的话,它能够简化像回滚,重存数据,复制数据等任务的操作,这可以通过日志文件来完成。

    流水线缺点

    流水线并发模型的主要缺点是作业的执行经常分布在多个工作者中,因此分布在你的项目中的多个class类中,这将很难精确的观察哪部分代码正在被执行。

    它也可能很难去编写代码。工作者代码有时被写成回调处理,有许多嵌套回调处理的代码让一些开发者称之为回调噩梦 (callback hell)。回调噩梦意味着它很难跟踪什么代码正在工作,以及确保每个回调能够获取它需要的数据。

    此时使用并行工作者并发模型往往很简单,你可以打开工作者代码文件并且从头到尾的阅读代码。当然并行工作者代码也可能分布在不同的类中,但是执行序列一般很容易从代码中阅读了解。

    函数式并行

    函数式并行是第三种并发模型,最近被讨论了很多(2015)。

    函数式并行的基本思想是使用函数调用实现你的程序。函数可以被视作"代理人"或者"执行人"然后彼此发送信息,这很像流水线并发模型(也叫做响应式或事件驱动系统)。当一个函数调用另一个时,这与发送一个消息相似。

    所有传给函数的参数被拷贝,所以没有接受参数的函数之外的实例可以操纵数据。这个拷贝对于避免共享数据竞争条件是必要的,也使得函数执行和一个原子操作相似。每个函数调用可以独立于其他函数调用被执行。

    每个函数调用可以被独立执行,因此每个函数调用可以在不同的CPU上执行,那意味着一个为实用而实现的算法(an algorithm implemented functionally)可以在多个CPU上并行执行。

    在Java 7中我们可以使用java.util.concurrent包,包含 ForkAndJoinPool ,可以帮助你实现类似于函数式并行的一些行为。在Java 8中我们可以使用并行 streams ,可以帮助你并行遍历大的集合。

    函数式并行困难的部分是知道哪些函数调用是并行的。通过CPU协调函数调用伴随着一定的开销,通过一个函数完成的工作单元需要值得这个开销才行。如果函数很小,尝试使他们并行可能会比单线程,单CPU执行更慢。

    从我的理解 (这一点都不完美), 你可以实现一个基于响应式、事件驱动模型的算法, 并实现类似于通过函数并行实现的工作分解。有了一个更加驱动的模型(driven model), 你就能更好地控制要并行什么以及多少(在我看来)。

    此外,将任务拆在多个 CPU上引发的协调的开销,只有在该任务当前是程序正在执行的唯一任务时才有意义。但是, 如果系统同时执行多个其他任务 (例如 web 服务器、数据库服务器和许多其他系统),则尝试并行化单个任务是没有意义的。无论如何,计算机中的其他CPU都会忙于处理其他任务,因此没有理由尝试用较慢的、函数式并行的任务来干扰它们。使用流水线 (响应式) 并发模型可能会更好, 因为它的开销较小 (在单线程模式下按顺序执行), 并且更符合底层硬件的工作方式。

    哪个并发模型最好?

    所以,哪个并发模型更好呢?

    像往常一样,这个答案依赖于你的系统想要做什么。如果你的工作是并行的,独立的并且没有共享状态的必要,你可以使用并行工作者模型来实现。

    许多工作不是并行的和独立的,对于这种类型的系统我相信流水线并发模型会有更多的好处,并且比起并行工作者模型来说也更好。

    你甚至不需要自己编写流水线并发模型底层,现代平台例如 Vert.x 已经为你实现了许多。

    同一线程 Same-threading

    同一线程是一个单线程系统被拓展为N个单线程系统的的并发模型。最终结果是N个单线程系统并行运行。

    同一线程系统不是纯粹的单线程系统,因为它包含多个线程。但是 —— 每个线程都像单线程系统一样运行。

    为什么使用单线程系统?

    您可能想知道为什么有人会在今天设计单线程系统。单线程系统已经普及,因为它们的并发模型比多线程系统简单得多。单线程系统不与其他线程共享任何数据。这使单线程能够使用非并发数据结构,并且可以更好地利用CPU和CPU缓存。

    但不幸的是,单线程系统无法充分利用现代CPU。现代CPU通常配有2个、4个或更多核心。每个核心都可以作为单独的CPU运行。单线程系统只能使用其中一个内核,如下所示:

    A single-threaded system running on a 4 core CPU.

    同一线程,单线程扩展

    为了利用CPU中的所有内核,可以扩展单线程系统以利用整个计算机。

    每个CPU一个线程

    同一线程系统通常在计算机中每个CPU运行1个线程。如果计算机包含4个CPU或CPU具有4个内核,则运行同一线程系统的4个实例(4个单线程系统)是很正常的。下图显示了这一原则:

    A same-threaded system running on a 4 core CPU.

    不共享状态

    同一线程系统看起来类似于多线程系统,因为同一线程系统也运行多个线程,但是有一个微妙的区别。

    同一线程和多线程系统之间的区别在于同一线程系统中的线程不共享状态。线程并不并发访问共享内存,也没有线程共享数据的并发数据结构等。这种差异在这里说明:

    不共享状态使每个线程表现的像是单线程系统。但是,由于同一线程系统可以包含多个线程,因此它实际上不是“单线程系统”。因为缺乏更好的名称,我发现将这样的系统称为同一线程系统更精确,而不是“具有单线程设计的多线程系统”。同一线程更容易表达,更容易理解。

    同一线程基本上意味着数据处理一直在同一个线程中,并且同一线程系统中的任何线程不会并发使用共享数据。

    负载分配

    显然,同一线程系统需要在运行的单线程实例之间共享工作负载。如果不这样,只有一个实例可以得到所有工作,系统实际上是单线程的。

    具体如何在不同实例上分配负载取决于系统的设计,我将在以下部分介绍几个。

    1.单线程微服务

    如果您的系统由多个微服务组成,则每个微服务都可以在单线程模式下运行。当您将多个单线程微服务部署到同一台机器时,每个微服务都可以在一个CPU上启动一个线程运行。

    微服务本质上不共享任何数据,因此微服务是同一线程系统的一个很好的用例。

    2.具有分片数据的服务

    如果您的系统确实需要共享数据,或者至少需要共享数据库,则可以对数据库进行分片。分片意味着数据在多个数据库之间分配。通常对数据进行划分,使得彼此相关的所有数据一起位于同一数据库中。例如,属于某个“owner”实体的所有数据都插入到同一数据库中。但是,分片不在本教程的范围内,因此你有兴趣的话,需要自己搜索有关该主题的教程。

    线程通信

    如果同一线程系统中的线程需要通信,则它们通过消息传递来实现。想要向线程A发送消息的线程可以通过生成消息(字节序列)来实现。然后,线程B可以复制该消息(字节序列)并读取它。通过复制消息,线程B可以确保线程A在自己读取时不会修改消息。复制后,它对于线程A来说是不可变的。

    通过消息传递的线程通信如下所示:

    线程通信可以通过队列,管道,UNIX套接字,TCP套接字等实现,具体看哪个适合您的系统。

    更简单的并发模型

    在同一线程系统中,它自己的线程中运行的每个系统都可以像单线程一样实现。这意味着它的内部并发模型会变得比线程共享状态简单得多,您不必担心并发数据结构以及此类数据结构可能导致的所有并发问题。

    插图

    以下是单线程,多线程和同一线程系统的说明,因此您可以更轻松地了解它们之间的差异。

    第一个插图展现了单线程系统。

    第二个图展现了一个多线程系统,其中线程共享数据。

    第三个图展现了一个有2个线程且每个线程具有自己数据的同一线程系统,通过将消息相互传递进行通信。

    并发 vs. 并行

    术语并发并行 通常用于多线程程序。但是并发和并行究竟意味着什么,它们是相同的术语还是什么?

    最简洁的答案是“不是”。它们不是相同的术语,尽管它们在表面上看起来非常相似。我花了一些时间才最终找到并理解并发和并行之间的区别。因此,我决定在这个Java并发教程中添加这个关于并发和并行的章节。

    并发

    并发意味着应用程序有多个任务同时进行(并发)。如果计算机只有一个CPU,则应用程序无法同一时间 在多个任务上取得进展 ,但是在应用程序内部有多个任务正在执行。在下一个任务开始之前,它并没有完全完成任务。

    并行

    并行意味着应用程序将其任务分成较小的子任务,这些子任务可以并行处理,例如在同一时间在多个CPU上。

    image

    Concurrency和Parallelism在细节上的区别

    如你所见,并发与应用程序处理其工作的多个任务有关。应用程序可以在某个时间(顺序)处理一个任务,或者同时处理多个任务(同时)。

    另一方面,并​​行与应用程序处理每个单独任务的方式有关。应用程序可以从开始到结束连续地处理任务,或者将任务分成可以并行完成的子任务。

    如您所见,应用程序可以是并发的,但不是并行的。这意味着它同时处理多个任务,但任务不会分解为子任务。

    应用程序也可以是并行的但不是并发的。这意味着应用程序一次只能处理一个任务,并且此任务被分解为可以并行处理的子任务。

    此外,应用程序可以是既不并发也不并行的。这意味着它一次只能处理一个任务,并且任务永远不会分解为并行执行的子任务。

    最后,应用程序也可以是既并发又并行的,因为它既可以同时处理多个任务,也可以将每个任务分解为子任务以便并行执行。但是,在这种情况下,并发和并行的一些好处可能会丢失,因为计算机中的CPU已经相当忙于单独的并发或并行。将它组合在一起可能只会带来很小的性能提升甚至性能损失。在盲目采用并发并行模型之前,请确保进行分析和测量。

    以下为原网站:

    相关文章

      网友评论

          本文标题:二. 并发和多线程基础理论

          本文链接:https://www.haomeiwen.com/subject/jqpdyqtx.html