当前位置: 首页 > 科技观察

操作系统与并发的爱恨纠葛

时间:2023-03-20 21:22:10 科技观察

一直不急于写并发的原因是看不懂操作系统。现在,又刷了操作系统。这次尝试写一些并发,看看能不能写清楚,拙劣的小编在线求助。。。打算用操作系统和并发同时结合的方法。并发历史最早的计算机没有操作系统,执行一个程序只需要一个进程,就是从头到尾依次执行。任何资源都会为这个程序服务,难免会出现资源浪费。?这里所说的资源浪费,是指资源闲置,没有得到充分利用的情况。?操作系统为我们的程序带来了并发性,操作系统让我们的程序可以同时运行多个程序。一个程序就是一个进程,相当于同时运行多个进程。操作系统是一个并发系统。并发是操作系统的一个非常重要的特性。操作系统具有同时处理和调度多个程序的能力。例如多个I/O设备同时输入输出;设备I/O和CPU计算同时进行;内存中的多个系统程序和用户程序交替启动,相互穿插。操作系统在协调和分配进程的同时,操作系统也为不同的进程分配不同的资源。操作系统使多个程序可以同时运行,解决了单个程序无法做到的问题。资源利用主要有3点。上面我们提到,单个进程存在资源浪费。例如,当你为文件夹授权时,输入程序不能接受外部输入的字符,只有授权完成后才能接受外部输入。一般来说,就是等待程序期间不能进行其他工作。如果可以在等待另一个程序的同时运行另一个程序,将大大提高资源的利用率。(资源不会觉得累)因为不会划桨~公平,不同的用户和程序对电脑上的资源有相同的使用权。一种高效的运行方式是为不同的程序划分时间片来使用资源,但需要注意的是,操作系统可以确定不同进程的优先级,虽然每个进程都有公平共享资源的权利,但每次前一个进程释放资源后,同时有一个优先级高的进程抢夺资源,这会导致优先级低的进程无法获得资源,久而久之就会导致进程饥饿。方便,单个进程无法通信。我认为沟通实际上是一种避雷针策略。通信的本质是信息交换。及时的信息交流可以避免信息孤岛和重复性工作;凡是可以并发完成的,顺序编程也可以实现,但是这种方式效率很低,属于阻塞式。然而,顺序编程(也称为串行编程)并非没有用。串行编程的优势在于它的“直观性和简单性”。客观来说,串行编程更适合我们人脑的思维方式,但我们不会满足于串行编程,“我们更想要它!!!”。资源利用、公平性和便利性驱动进程和线程。如果你还不明白进程和线程的区别,那我就用我多年的操作系统经验(吹牛,其实半年)给你解释一下:“进程是一个应用程序,线程是一个应用程序中的应用程序。顺序流”。或者阮一峰老师也从https://www.ruanyifeng.com/blog/2013/04/processes_and_threads.html给大家做了通俗易懂的解释线程会共享进程范围的资源,比如内存和文件句柄,但是每个线程也有自己的私有内容,如程序计数器、栈、局部变量等。下面总结一下进程共享资源和线程共享资源的区别。线程被描述为一个轻量级进程。轻量体现在线程的创建和销毁比进程的开销要小很多。?注意:任何比较都是相对的。?现代操作系统大多以线程为基本调度单位,因此我们的视角主要集中在线程的探索上。线程优缺点合理使用线程是一门艺术,合理地编写一个准确的多线程程序更是一门艺术。如果线程使用得当,可以有效降低程序开发和维护的成本。在GUI中,线程可以提高用户界面的响应能力,而在服务器应用程序中,并发可以提高资源利用率和系统吞吐量。Java在用户空间很好地实现了开发包,在内核空间提供了系统调用,支持多线程编程。Java支持丰富的类库java.util.concurrent和跨平台的内存模型,也提高了开发人员的门槛,并发一直是一个高大上的话题,而现在,并发也成为了主流必备的素质开发商。虽然线程带来的好处很多,但是编写正确的多线程(并发)程序却异常困难。并发程序中的bug经常会莫名其妙地出现和消失。当你认为没有问题的时候,它就会出现,难以定位是并发程序的一个特点,所以在此基础上你需要有扎实的并发基础。那么,为什么会出现并发呢?为什么并发计算机世界的快速发展离不开CPU、内存、I/O设备的快速发展,但这三者一直存在速度差异的问题。我们可以从内存层次结构中学习。可见CPU内部是寄存器结构。寄存器的访问速度高于缓存,缓存的访问速度高于内存。最慢的是磁盘访问。程序在内存中执行。程序中的大部分语句都需要访问内存,有的还需要访问I/O设备。根据漏桶理论,程序的整体性能取决于最慢的操作,也就是磁盘访问速度。由于CPU速度太快,为了发挥CPU的速度优势,平衡三者的速度差异,计算机体系结构、操作系统、编译器都做出了贡献,主要体现在:CPU使用缓存为了中和内存访问速度的差异,操作系统提供进程和线程调度,让CPU在执行指令的同时,对线程进行时分复用,让内存和磁盘不断交互,不同的CPU时间片可以执行不同的任务,从而平衡三者的差异编译器提供优化指令的执行顺序,从而合理使用缓存。在享受这些便利的同时,多线程也给我们带来了挑战。下面讨论一下为什么会出现并发问题,以及多线程的根源。线程带来了哪些安全问题?线程安全性非常复杂。如果没有同步机制,多线程中的执行操作往往是不可预测的。这也是多线程带来的挑战之一。下面我们来一段代码,看看安全问题体现在哪里。publicclassTSynchronizedimplementsRunnable{staticinti=0;publicvoidincrease(){i++;}@Overridepublicvoidrun(){for(inti=0;i<1000;i++){increase();}}publicstaticvoidmain(String[]args)throwsInterruptedException{TSynchronizedtSynchronized=newTSynchronized();ThreadaThread=newThread(tSynchronized);ThreadbThread=newThread(tSynchronized);aThread.start();bThread.start();System.out.println("i="+i);}}这段程序输出后,你会发现每次i的值都不一样,这不符合我们的预测,那为什么会这样呢?我们先来分析一下程序流程的运行。TSynchronized实现了Runnable接口,定义了一个静态变量i,然后在increase方法中每次增加i的值,并在其实现的run方法中循环调用,一共执行1000次。可见性问题在单核CPU时代,所有线程共享一个CPU,CPU缓存和内存的一致性很容易解决。如果用一张图来表示CPU和内存的关系,我想会是这样的。在多核时代,因为有多个核心的存在,每个核心都可以独立运行一个线程,每个CPU都有自己的缓存。这时候CPU缓存和内存的数据一致性就没那么容易解决了。当多个线程运行在不同的CPU上执行时,这些线程运行在不同的CPU缓存上。因为i是一个静态变量,没有任何线程安全保护,多个线程会并发修改i的值,所以我们认为i不是线程安全的,导致这个结果的发生是因为在aThread中读取的i值和bThread互不可见,所以这是一个由于可见性引起的线程安全问题。原子性问题一个看似普通的程序,由于aThread和bThread两个线程的交替执行,产生了不同的结果。但是根本原因并不是创建两个线程造成的。多线程只是线程安全的必要条件。最终的根本原因是i++操作。这个操作有什么问题?这不是一个增加我的操作吗?即“i++=>i=i+1”,这怎么会出问题呢?因为i++不是原子操作,仔细想想,i++其实就是三步,读取i的值,进行i+1操作,然后将i+1得到的值重新赋值给i(写结果入内存)。当两个线程开始运行时,每个线程都会将i的值读入CPU缓存,然后进行+1操作,然后将+1之后的值写入内存。因为线程有自己的虚拟机栈和程序计数器,它们之间没有数据交换,所以当aThread执行+1操作时,会将数据写入内存,同时,bThread执行+1操作后1操作,它也会将write数据写入内存,因为CPU时间片的执行周期是不确定的,所以当aThread还没有把数据写入内存时,bThread会读取内存中的数据,然后执行+1操作,然后写回内存,从而覆盖i的值,导致aThread所做的努力白费。为什么上面的线程切换会有问题呢?我们先考虑正常情况下(即没有线程安全问题时)两个线程的执行顺序。可以看出,当aThread在执行整个i++操作时,最后操作系统从aThread->bThread切换线程,这是最理想的操作。一旦操作系统在任何读/增/写阶段切换线程,就会出现线程安全问题。例如下图,一开始,内存中i=0,aThread读取内存中的值读入自己的寄存器,执行+1操作。这时发生线程切换,bThread开始执行。取内存中的值,读入自己的寄存器。这时候就会发生线程切换。线程切换到aThread开始运行。aThread将自己寄存器的值写回内存。->bThread,线程bThread将自己寄存器的值加1,然后写回内存。写入后,内存中的值不是2,而是1,内存中i的值被覆盖。上面我们提到了原子性的概念,那么什么是原子性呢??并发编程的原子操作是完全独立于任何其他进程操作的操作。原子操作主要用于现代操作系统和并行处理系统。原子操作通常用在内核中,因为内核是操作系统的主要组成部分。然而,大多数计算机硬件、编译器和库也提供原子操作。在加载和存储中,计算机硬件读取和写入内存字。用于匹配、递增或递减值,通常通过原子操作。在原子操作期间,处理器可以在同一数据传输期间完成读取和写入。这样,在原子操作完成之前,其他I/O机制或处理器无法执行内存读取或写入任务。?简单来说就是“原子操作要么完全执行,要么根本不执行”。数据库事务的原子性也是基于这个概念演化而来的。有序问题也带来了并发编程中非常头疼的“有序”问题。orderly,顾名思义,就是sequential,指的是指令在计算机中执行的先后顺序。一个非常明显的例子是JVM中的类加载。这是JVM加载类的过程图,也称为类的生命周期。类从加载到JVM到卸载会经历五个阶段,卸载”。这五个过程的执行顺序是一定的,但在连接阶段,也会分为三个过程,即“验证、准备、分析”阶段。在一个阶段的执行过程中,另一个阶段被激活。排序的问题一般是编译器带来的。有时候编译器确实是“好心办坏事”。为了优化系统性能,它经常改变指令的执行顺序。活性问题多线程也会带来活性问题。如何定义活性问题?Liveness问题关注的是“是否会发生某事”。“如果一组线程中的每个线程都在等待一个只能由组中另一个线程触发的事件,这种情况会导致死锁”。简单来说就是每个线程都在等待其他线程释放资源,其他资源也在等待每个线程释放资源,这样就没有线程先释放自己的资源。这种情况会造成死锁,所有线程都会Infinity等待。换句话说,死锁线程集中的每个线程都在等待另一个死锁线程持有的资源。但是由于没有一个线程可以运行,没有一个可以释放资源,所以没有一个可以被唤醒。如果说死锁很痴情的话,那么livelock用成语来说就是弄巧成拙。在某些情况下,当一个线程意识到它无法获得它需要的下一个锁时,它会尝试礼貌地释放它已经获得的锁,然后等待很短的时间再试一次。你可以想象这样的场景:当两个人在狭路相逢时,都想给对方让路,同样的步伐会导致双方都无法前进。现在想象一对使用两种资源的并行线程。在各自尝试获取另一把锁失败后,两个线程都会释放持有的锁并再次尝试,如此循环往复。很明显,这个过程中没有线程阻塞,但是线程还是不会往下执行。我们称这种情况为活锁(livelock)。如果我们期望的事情永远不会发生,就会出现活性问题,比如在单线程中会死循环while(true){...}for(;;){}在多线程中,比如aThread和bThread都是某种需要资源,aThread一直占用资源不释放,bThread一直没有执行,会造成liveness问题,bThread线程会饿死,后面会讲到。性能问题与活性问题密切相关。如果说活性问题关注的是最终结果,那么性能问题关注的是导致结果的过程。性能问题有很多方面:比如服务时间长,吞吐率太低,资源消耗太高,这样的问题在多线程中也存在。在多线程中,有一个非常重要的性能因素就是我们上面提到的线程切换,也称为上下文切换(ContextSwitch),这个操作是非常昂贵的。?在计算机界,老外喜欢用上下文这个词,它涵盖的内容非常多,包括上下文切换资源、寄存器状态、程序计数器等,上下文切换一般指的是这些上下文切换资源和寄存器状态、程序计数器变化等?在上下文切换中,上下文的保存和恢复,局部性丢失,大量时间花费在线程切换上,而不是线程运行上。为什么线程切换如此昂贵?线程之间的切换涉及以下步骤将CPU从一个线程切换到另一个线程涉及挂起当前线程,保存它的状态,比如寄存器,然后恢复到被切换线程的状态,加载一个新的程序计数器,以及此时线程切换实际上已经完成;此时CPU并没有在执行线程切换代码,而是在执行与线程关联的新代码。引起线程上下文切换的几种方式线程之间的切换一般是操作系统层面需要考虑的问题,那么引起线程上下文切换的方式有哪些呢?或者线程切换的动机是什么?主要有以下几种原因上下文切换的方式是当前正在执行的任务完成,系统的CPU正常调度下一个需要运行的线程。当当前正在执行的任务遇到I/O等阻塞操作时,线程调度器会挂起该任务,继续调度下一个任务。多个任务并发抢占锁资源。当前任务没有获得锁资源,被线程调度器挂起,继续调度下一个任务。用户代码暂停当前任务,如线程执行sleep方法,让出CPU。使用硬件中断引起上下文切换线程安全在Java中,要实现线程安全,必须正确使用线程和锁,但这些只是满足线程安全的一种方式。编写正确的线程安全代码,其核心是管理状态访问操作。最重要的是最共享(Shared)和可变(Mutable)的状态。只有共享变量和可变变量会有问题,私有变量不会有问题,参考程序计数器。对象的状态可以理解为存储在实例变量或静态变量中的数据。共享是指一个变量可以被多个线程同时访问,可变是指变量在其生命周期内会发生变化。一个变量是否线程安全取决于它是否被多个线程访问。为了安全地访问变量,必须通过同步机制修改变量。如果不使用同步机制,那么就必须避免多线程访问共享变量。主要有两种方法:不要在多个线程之间共享变量,并使共享变量不可变。关于线程安全,我们已经说过很多次了,那么什么是线程安全呢?什么是线程安全根据上面的讨论,我们可以得出一个简单的定义:“当多个线程访问某个类时,这个类总能表现出正确的行为,则称这个类是线程安全的”。单线程就是线程数为1的多线程,单线程一定是线程安全的。读取一个变量的值不会带来安全问题,因为无论读取多少次,这个变量的值都不会被修改。原子性我们在上面提到了原子性的概念。您可以将原子操作视为一个不可分割的整体。结果只有两种,要么全部执行,要么全部回滚。您可以将原子性视为一种婚姻关系。男人和女人只能产生两种结果,好或坏。一般来说,人的一生可以看作是一种原子性。当然我们也不排除时间管理(线程切换)的情况。我们知道,线程切换必然伴随着安全问题。男人要出去,也会造成两种结果。这两个结果对应了security的两个结果:threadsafety(好)和thread-unsafe(说完了)。竞争条件有了上面线程切换的知识,竞争条件就很容易定义了。它是指“当两个或多个线程同时修改共享数据,从而影响程序运行的正确性时,这称为竞争条件”。线程切换是导致竞争条件出现的诱发因素。让我们用一个例子来说明它。我们来看一段代码publicclassRaceCondition{privateSignletonsingle=null;publicSignletonnewSingleton(){if(single==null){single=newSignleton();}returnssingle;}}在上面的代码中,涉及到一个racecondition,即,判断single时,如果判断single为空,此时发生线程切换。另一个线程执行,判断single的时候也是空的,执行new操作,然后线程切换回之前的线程,再执行new操作,内存中就会有两个Singleton对象。锁定机制在Java中,有许多方法可以锁定和保护共享和可变资源。Java提供了一种内建的资源保护机制:synchronized关键字,它有三种保护机制对方法进行加锁,保证在多个线程中只有一个线程执行该方法;,变量可以用对象代替)进行加锁,保证多个线程中只有一个线程可以访问对象实例;锁定类对象,保证多个线程中只有一个可以访问类中的资源。synchronized关键字保护资源的代码块俗称同步代码块(SynchronizedBlock),如synchronized(锁){//线程安全代码}每个Java对象都可以作为锁来实现同步。这些锁称为内置锁(InstrinsicLock)或监控锁(MonitorLock)。线程在进入同步代码前自动获取锁,退出同步代码时自动释放锁。无论是通过正常执行路径退出还是通过异常路径退出,获得内置锁的唯一途径就是进入受锁保护的同步代码。块或方法。synchronized的另一个隐含语义是互斥。互斥意味着排斥。最多只有一个线程持有锁。当线程A试图获取线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放锁。如果线程B不释放锁,线程A就会一直等待下去。当线程A获得线程B持有的锁时,线程A必须等待或者阻塞,但是获得锁的线程B可以重新进入,重新进入的意思可以用一段代码来表达publicclassRetreent{publicsynchronizedvoiddoSomething(){doSomethingElse();System.out.println("doSomething...");}publicsynchronizedvoiddoSomethingElse(){System.out.println("doSomethingElse...");}获取doSomething()方法锁的线程可以执行doSomethingElse()方法,执行完可以重新执行doSomething()方法中的内容。锁重入也支持子类和父类之间的重入,后面我们会介绍。volatile是轻量级的synchronized,也就是轻量级的加锁方式。Volatile通过确保共享变量的可见性从侧面锁定对象。可见性是指当一个线程修改共享变量时,另一个线程可以看到修改后的值。volatile的执行成本远低于synchronized,因为volatile不会引起线程上下文切换。volatile的具体实现我们后面再说。我们还可以使用原子类来确保线程安全。原子类其实就是rt.jar下以atomic开头的类。此外,我们还可以使用java.util.concurrent工具包下的线程安全集合类来保证线程安全。Security,具体的实现类和它的原理我们后面会讲到。本文转载自微信公众号“JavaBuilder”,可通过以下二维码关注。如需转载本文,请联系Java开发者公众号。