java基础-----进程、线程与同步基本概念

  我记得曾有人说过,再简单的事儿乘以13亿就是复杂上天的事儿。这句话原本是用在描述我国的国情,但是我觉得这句话中透露出的思想却是深邃的,这句话在编程的世界中依然受用。很多时候我们的一个操作在只有一个线程的时候感觉很和谐,但是到了多线程环境,复杂度立马上天。

一、问题的起源

  在初窥多线程的世界之前,我们先要思考一个问题,既然单线程/单进程那么完美,我们为什么还要用多线程呢?

  在上古时期,那时的CPU不像现在这么强大,不像现在的CPU一搞就是4核8线程啥的,当时以8086为代表,就是存粹的单核处理器,也没有超线程技术。而在远古时期,CPU也被用来做一些很存粹的活儿,没有多任务的需求,接触过嵌入式的孩子们都应该知道,在一些简单单片机如8051、STM32F系列的开发中,直接用死循环写主业务,在上古时期的计算机实际上也是这样的,没有操作系统,业务直接上裸机。但是逐渐的,人们发现越来越多的事务都可以交给计算机来处理,但是此时的计算资源太过昂贵,如果每个业务都用一台独立的计算机来处理,成本将高出天际,说白了此时就是生产力与生产关系开始变得不平衡,每当这种时候就会引起变革。当时的人们分析发现,业务需要和外部环境进行交互,但是环境的变化是缓慢的,而CPU的处理是急速的,此时就会出现CPU空转等待环境响应的情况,此时计算资源极度的浪费。在这种背景下,我们自然而然的想到了时分复用。我觉得世间很多东西都是相通的,就像通信系统中从1G到2G的变革不仅仅是模拟信号变化到数字信号,更是从频谱独占变革到了时分复用,极大的提升了系统容量。在计算机的演变中,这一过程相当于从1G变革到了2G,有着极大的意义。

  在计算资源时分复用的模型中,产生了一个概念,每一个需要解决的业务我们称之为一个任务,例如需要在同一台计算机上完成密码破解、文字编辑以及邮件收发,我们称之为在该台计算机上运行着三个任务。既然是分时复用,那就意味着每个任务只能在一个小时间片内占据CPU时间,到时间了就要让出CPU权限。但是任务和任务之间是对等的,任务也是自私的,每个任务都想自己尽可能地多占用CPU时间。为了协调这种关系,需要一个仲裁者,而操作系统就起到了仲裁者的角色。

  在上古时代,计算机都是单核CPU,实际上在同一时刻,有且仅有一个任务(包括操作系统)占据CPU的主导权,而任务之间通过时分复用的方式,从宏观上给人以多任务并行的假象。远古时代的CPU和内存直接相连,但是渐渐的CPU越来越快,而内存的速度却赶不上CPU的速度,因此每次取指令时,CPU都要空转等待内存,人们发现这样一来,CPU又可以偷懒,为了进一步提升CPU的效率,在CPU和内存之间增加了一个cache层。CPU和内存的连接方式如下图所示。

  在多任务操作系统中,我们要明确这么几个概念:线程、进程。不过在这之前我们需要明确什么是程序。我们编写的文本程序代码,例如C,C++,java这些文件是给人看的文本文件,这些文件经过编译器的编译和连接之后编程机器能够识别的二进制文件,这些文件存储在磁盘上,我们称之为程序。程序本身其实就是一个文件而已,并不能获取CPU的控制权,即此时的他无法运行。但是一旦用户选择了运行该程序,此时操作系统开始为程序的运行做准备,例如为程序分配内存空间分配资源,随后将程序的二进制文件加载进内存,初始化变量等。这个时候,我们将其称之为进程,即进程是将二进制程序文件加载进内存并为之分配相应的资源的实体,说白了进程就是一个具备运行条件的程序,这也是为什么我们称之为进程是操作系统资源分配的最小单位。

  如果把进程比喻成一个车间,那么线程就是车间中的流水线。每个流水线都可以进行物资生产,但是同时他们都公用车间的资源,例如公用车间的电力设施,照明设施,消防设施等。线程是比进程粒度更小的单位,每个进程至少有一个线程,同时CPU在进行任务调度时以线程为最小单位。这就相当于在一个工厂中有多个车间,但是只有一个供电插头,为了确保工厂的运转,那么每个流水线轮流接电工作。我们在理解线程和进程概念的时候可以大致的将进程想象为一个车间棚子,这个车间提供了各种资源,而线程就是车间里面的流水线,真正生产产品的是流水线,流水线可以随意的获取车间所提供的各种资源,当然了每个流水线也有自己专属的东西,例如流水线上的工人。那么对应到进程线程的概念中,我们应当明确的是,操作系统在做资源分配时,以进程为最小单位,每个进程中至少有一个线程,线程共享进程的堆内存,每个进程拥有自己独立的寄存器资源、栈资源。CPU的调度以线程为最小单位,就必然导致了线程的寄存器、栈无法共享。

  在多任务操作系统时代,不仅在一台电脑上可以同时处理多个任务,每个任务也可以使用多线程的方式进行实现,那么必然的,多线程将会带来一些从未遇到过的问题。考虑一下的场景,按照理想的情况,i最后的值应当是2。但是现实就是喜欢打脸。

1 int i=0;
2 //线程A
3 i++;
4  //线程B
5 i++;

  我们来分析一下,A线程对i做了自增操作,实际上这个操作包含三个步骤,首先是从内存中读取变量 i 到寄存器,然后对寄存器中的值进行加一,然后写回内存。从理论上而言,此时内存中 i 的值应当是2,但是问题就出在现代编译器会进行指令重排,并不会立即将 i 写回内存,因为如果后续要用,又要从内存读取,太费时间。这就导致从寄存器写入到内存的时机我们无法控制。假定线程A执行了自增操作后,时间片到了,此时 i 的值还在寄存器中,那么线程B登场,他同样需要在cache或者内存中读取,但是读到的值却依然是0,理所当然,自增操作后刷新内存是1,当调度到A之后,A将其写回内存,也是1。问题就处在这,按照我们的设想,应该是2才对,但是现实告诉我们,他可能是1。

  后来进入到多核时代,每个CPU核心都拥有自己独立的cache,事情变得更加复杂了起来,世界也从伪并行进入到了真并行阶段。

  二、操作系统层面同步的基本概念

  和多线程带来的效益相比,这点挑战微不足道,因此天才的工程师想出了各种办法来解决这个问题。在操作系统层面,我们将可能会引起冲突的资源叫做临界资源,更准确的,我们将同一时刻只允许一个线程访问的资源称之为临界资源。线程访问临界资源的规则我们称之为线程同步。

  我们首先用现实生活中的例子来理解什么是临界资源什么是线程同步

  假定我们在一个商场里面买衣服,每个人都是一个线程,而我们每个人都需要在试衣间去试衣服,试衣间是每个线程都需要争抢的资源, 我们将试衣间称之为临界资源。我想每个人都不愿意在自己试衣服的时候有人进来,那么现实生活中又是怎么实现线程同步的呢?依据生活经验,每个试衣间都有一个带锁的门,我们在需要使用的时候看看这个门能不能打开,如果可以打开的话说明没人用,那么我们就可以进去用,并且将门从里面反锁,通过这个规则,所有人都能够序列式的进去试衣间,人也就实现了同步。

  相应的在操作系统层面也提供了相应的线程同步的方式,分别是:临界区、互斥量、信号量、事件。

  临界区是访问临界资源的代码片段,这就相当于我们在找试衣间的时候不自己去找了,我们是这个商场的VIP客户,我们选好衣服之后,打了一个响指,此时来了一个体态婀娜的服务员,你对她说要找一个试衣间来试衣服,此时将由她来负责为你寻找一个空闲的试衣间。在这种场景下,顾客都通过这个服务员来访问试衣间,而服务员保证只有在一个人出了试衣间后才会有下一个人进入试衣间,这个时候我们将这个体态婀娜的服务员称之为临界区。

  互斥量一种特殊的信号量,实际上而言它可以看作0-1信号量。在这种场景下,商场有点小,只有一个试衣间,而且试衣间有一个牌子,当有人进去之后,关上门,门上会显示 “使用中” 这三个字,每当人看到了这三个字,那么就意味着里面有人,需要等待。

  信号量是一种控制数量有限资源访问的方法。在这种场景下,商场扩建了,建立了一个试衣区,试衣区有多个试衣间供顾客使用,此时在试衣区门口有一个牌子,上面显示着里面还有多少空余的试衣间,如果有一个人进去了试衣区,那么空余试衣间数量减一,上述这个操作我们称之为P操作;若有顾客从试衣区出来,那么空余试衣间数量加一,这个操作称之为V操作。每个顾客在到达试衣区门口,首先实行P操作,如果试衣间剩余量非负,那么顾客就直接进入试衣区,如果小于零,则被告知试衣间数列不够,需要等待。

  事件是一种通过通知线程某一事情已经发生并启动线程开始的同步办法。在这个场景中,商场进一步升级,在试衣区加了一个管理员,我们在选好衣服以后跑到试衣区门口对着管理员说,有试衣间了记得通知我,我去买个奶茶。那么此时,当存在可用的试衣间,管理员会马上跑过来通知你先别喝奶茶了赶紧试衣服。

  三、语言层面同步的基本概念

  在语言层面,我们多以原子操作、锁来解决线程同步问题,而锁的底层也可以通过操作系统的同步方式来实现。语言层面的同步以java为例。

  原子操作是通过一条无法被打断的汇编指令来实现同步,例如若能够将上述例子中的自增操作编译成原子操作,那么这段代码就自带同步属性。CPU执行原子操作时,会将内存总线加锁,在一个时刻内,有且仅有该CPU能够访问该内存,并且会立即将更新后的值从寄存器写入到内存。

  说到原子操作就不得不说CAS(Compare And Set),这条指令时一个原子指令,它是首先读取内存中的某个变量,如果该变量和我们所期待的值一致,那么说明没有其他线程访问过该值,可以将新的值直接更新到内存,如果和我们所期望的值不一致,那么说明有别惹线程访问过,那么更新失败。CAS操作可以被看作是一个乐观锁。

  volatile关键字。这个关键字首先的含义就是禁止指令重排,保证有序性。我么知道编译器在编译代码的时候,为了提升效率,其指令并不一定和代码中的顺序一致,但是其保证单线程执行结果和代码顺序执行的结果一致,这个关键字就会使得代码在其前面的,经过编译后指令也一定在其前面,代码在其后面的经过编译后指令也一定在其后面。其二,该关键字使得强制刷新内存,保证可见性。被该关键字修饰的变量,在每次变更时都会立即写回内存,并且被该关键字修饰的变量每次更改都会造成其他cache中该变量的缓存失效,由此保证可见性。但是需要格外注意的是该关键字不保证原子性,这意味着虽然该指令保证变量变更会被立刻写回内存,但是写回操作可能会被打断。

  synchronized关键字,该关键字能够实现线程同步。在不同版本的java中,synchronized关键字有着不同的实现,在早期jdk版本中,synchronized关键字是当作一个重量级锁来实现,但是从jdk1.8后,对该关键字做了大量的优化。

  锁,顾名思义,为临界资源加锁。锁的分类方法有很多种,从重量级可以分为:轻量级锁、重量级锁、偏心锁;从行为可以分为:自旋锁和阻塞锁;从抢占角度可以分为:公平锁、非公平锁;从是否支持重入可以分为:递归锁、非递归锁;从对资源的锁定情况可以分为:共享锁、独占锁;从对资源竞争的态度可以分为:客观锁、悲观锁。

  

  

猜你喜欢

转载自www.cnblogs.com/establish/p/12669981.html