Java 并发:并发背景

x33g5p2x  于2021-11-18 转载在 Java  
字(4.8k)|赞(0)|评价(0)|浏览(527)

摘要:

并发与操作系统的生命历程息息相关。进程的出现,使得程序状态的保存变为现实,为进程间的切换提供了可能,实现了操作系统的并发,大大提高资源利用率。虽然进程的出现解决了操作系统的并发问题,但人们对实时性又有了更高的要求。由于一个进程由若干个子任务组成,所以人们就发明了线程,让每个线程负责一个独立的子任务,提高程序的响应灵敏度。一个进程虽然包括多个线程,但是这些线程是共同享有进程占有的资源和地址空间的。因此,虽然多线程提高了资源利用率,保证了实时性,但同时也带来了包括安全性、活跃性和性能等问题。总的来说,进程让操作系统的并发性成为可能,而线程让进程的内部并发成为可能。

一. 进程和线程的由来

(1). 操作系统中为什么会出现进程?

说起进程的由来,我们需要从操作系统的发展历史谈起。
  也许在今天,我们无法想象在很多年以前计算机是什么样子。我们现在可以用计算机来做很多事情:办公、娱乐、上网,但是在计算机刚出现的时候,是为了解决数学计算的问题,因为很多大量的计算通过人力去完成是很耗时间和人力成本的。在最初的时候,计算机只能接受一些特定的指令,用户输入一个指令,计算机就做一个操作。当用户在思考或者输入数据时,计算机就在等待。显然,这样效率会很低下,因为很多时候,计算机处于等待用户输入的状态。

那么,能不能把一系列需要操作的指令预先写下来,形成一个清单,然后一次性交给计算机,计算机不断地去读取指令来进行相应的操作?就这样,批处理操作系统诞生了。用户可以将需要执行的多个程序写在磁带上,然后交由计算机去读取并逐个地执行这些程序,并将输出结果写到另一个磁带上。

虽然批处理操作系统的诞生极大地提高了任务处理的便捷性,但是仍然存在一个很大的问题:

假如有两个任务 A 和 B,任务A 在执行到一半的过程中,需要读取大量的数据输入(I/O操作),而此时CPU只能静静地等待任务A读取完数据才能继续执行,这样就白白浪费了CPU资源。人们于是想,能否在 任务A 读取数据的过程中,让 任务B 去执行,当 任务A 读取完数据之后,让 任务B 暂停,然后让 任务A 继续执行?

但是这样就有一个问题,原来每次都是一个程序在计算机里面运行,也就说内存中始终只有一个程序的运行数据。而如果想要 任务A 执行 I/O操作 的时候,让 任务B 去执行,必然内存中要装入多个程序,那么如何处理呢?多个程序使用的数据如何进行辨别呢?并且,当一个程序运行暂停后,后面如何恢复到它之前执行的状态呢?

这个时候,人们就发明了进程,用进程来对应一个程序,每个进程对应一定的内存地址空间,并且只能使用它自己的内存空间,各个进程间互不干扰。并且,进程保存了程序每个时刻的运行状态,这样就为进程切换提供了可能。当进程暂停时,它会保存当前进程的状态(比如进程标识、进程的使用的资源等),在下一次重新切换回来时,便根据之前保存的状态进行恢复,然后继续执行。

这就是并发,能够让操作系统从宏观上看起来同一个时间段有多个任务在执行。换句话说,进程让操作系统的并发成为了可能。注意,虽然并发从宏观上看有多个任务在执行,但是事实上,任一个具体的时刻,只有一个任务在占用CPU资源(当然是对于单核CPU来说的)。

(2). 为什么会出现线程?

在出现了进程以后,操作系统的性能得到了大大的提升。虽然进程的出现解决了操作系统的并发问题,但是人们仍然不满足,人们逐渐对实时性 有了要求。因为一个进程在一个时间段内只能做一件事情,如果一个进程有多个子任务,只能逐个地去执行这些子任务。比如,对于一个监控系统来说,它不仅要把图像数据显示在画面上,还要与服务端进行通信获取图像数据,还要处理人们的交互操作。如果某一个时刻该系统正在与服务器通信获取图像数据,而用户又在监控系统上点击了某个按钮,那么该系统就要等待获取完图像数据之后才能处理用户的操作,如果获取图像数据需要耗费 10s,那么用户就只有一直等待。显然,对于这样的系统,人们是无法满足的。

那么,可不可以将这些子任务分开执行呢?即,在系统获取图像数据的同时,如果用户点击了某个按钮,则会暂停获取图像数据,而先去响应用户的操作(因为用户的操作往往执行时间很短),在处理完用户操作之后,再继续获取图像数据。人们就发明了线程,让一个线程去执行一个子任务,这样一个进程就包括了多个线程,每个线程负责一个独立的子任务。这样,在用户点击按钮的时候,就可以暂停获取图像数据的线程,让 UI线程 响应用户的操作,响应完之后再切换回来,让获取图像的线程得到 CPU资源 。从而,让用户感觉系统是同时在做多件事情的,满足了用户对实时性的要求。

**换句话说,进程让操作系统的并发性成为可能,而线程让进程的内部并发成为可能。**但是要注意,一个进程虽然包括多个线程,但是这些线程是共同享有进程占有的资源和地址空间的。进程 是操作系统进行资源分配的基本单位,而 线程 是操作系统进行调度的基本单位。

(3). 多线程并发

由于多个线程是共同占有所属进程的资源和地址空间的,那么就会存在一个问题:如果多个线程要同时访问某个资源,怎么处理? 这个问题就是并发安全性问题。

此外,可能有朋友会问,现在很多时候都采用多线程编程,那么是不是多线程的性能一定就由于单线程呢?答案是不一定,要看具体的任务以及计算机的配置。比如说:对于单核CPU,如果是 CPU密集型任务,如解压文件,多线程的性能反而不如单线程性能,因为解压文件需要一直占用 CPU资源,如果采用多线程,线程切换导致的开销反而会让性能下降。但是对于比如交互类型的任务,肯定是需要使用多线程的。而对于多核CPU,对于解压文件来说,多线程肯定优于单线程,因为多个线程能够更加充分利用每个核的资源。

虽然多线程能够提升程序性能,但是相对于单线程来说,它的编程要复杂地多,要考虑线程安全问题。因此,在实际编程过程中,要根据实际情况具体选择。

二. 并发简史总结

  • 早期的计算机不包含操作系统,它们从头到尾只执行一个程序,并且这个程序能够访问计算机中的所有资源。这对于昂贵且稀有的计算机资源来说是一种浪费;
  • 操作系统的出现使得计算机能同时运行多个程序,不同的程序都在单独的进程中运行,并且操作系统为各个独立执行的进程分配资源( eg: 通过粗粒度时间分片使程序共享资源,如 CPU 等 )。这无疑提高了计算机资源的利用率;
  • 在早期的分时系统中,每个进程的执行都是串行的。**串行编程模型的优势在于其简单性和直观性,因为它每次只做一件事情,做完之后再做另一件。**这种串行编程模型仍然存在着计算机资源利用率不高的问题;
  • 促使进程出现的因素同样也促使着线程的出现。线程允许在同一个进程中同时存在多个程序控制流。线程会共享进程范围内的资源,但每个线程都有各自的程序计数器以及局部变量等等;
  • **线程也被成为轻量级进程。在大多数现代操作系统中,都是以线程为基本的调度单位,而不是进程。**如果没有明确的协同机制,那么线程将彼此独立执行。由于同一个进程的所有线程都将共享进程的内存地址空间,因此这些线程都能访问相同的变量,这就需要实现一种比进程间共享数据粒度更细的数据共享机制。如果没有明确的同步机制来协同对共享数据的访问,将造成不可预测的结果。

三. 线程的优势

  • 解耦、简化程序开发

在程序中,如果我们为每种类型的任务都分配一个专门的线程,那么可以形成一种串行执行的假象,并将程序的执行逻辑与调度机制的细节,交替执行的操作,异步 I/O 以及资源等待等问题分离开来。通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行,并在特定的同步位置进行交互。
  
  Servlet 框架就是一个很好的例子。框架负责解决一些细节问题,包括请求管理、线程创建、负载平衡等,并在正确的时刻将请求分发给正确的应用程序组件(对应的一个具体Servlet)。编写 Servlet 的开发人员不需要了解有多少请求在同一时刻被处理,也不需要了解套接字的输入(出)流是否被阻塞。当调用 Servlet 的 service 方法来响应 Web请求时,可以以同步方式来处理这个请求,就好像它是一个单线程的程序。这种方式简化了组件的开发,大大降低框架学习门槛。

多线程还有助于用户界面的灵敏响应。例如,在 Android 开发中,我们常常将网路请求或 I/O 等耗时操作单独放到一个线程中,以提高响应的灵敏度。

  • 提高资源利用率

多处理器系统的出现,使得同一个程序的多个线程可以被同时调度到多个 CPU 上运行。因此,多线程程序可以通过提高处理器资源的利用率来提升系统的吞吐率。其实,多线程程序也有助于在单处理器系统上获得更高的吞吐率(如果程序的一个线程在等待 I/O 操作的完成,另一个线程可以继续运行,使程序能够在 I/O 阻塞期间继续运行)。

四. 线程带来的风险

  • 安全性问题

**在线程安全性的定义中,最核心的概念就是正确性。**当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。

线程不安全类示例:

@NotThreadSafe 
public class UnsafeSequence { 
    private int value;

    /** Returns a unique value. */
    public int getNext() { 
        return value++; 
    } 
}

虽然 递增运算 “value++” 看上去是单个操作,但实际上它包含三个独立的操作:读取 value, 将 value 加 1,并将计算结果写入 value。由于运行时各个线程执行顺序的不确定性,可能这段代码在不同线程的调用中返回相同的数值,如图:
             ![UnsafeSequence 的错误执行情况.png-66.9kB][1]

  • 活跃性问题

**活跃性问题关注的是:某件正确的事情最终会发生。**导致活跃性的问题包括死锁、饥饿等。

  • 性能问题

**性能问题关注的是:正确的事情能够尽快发生。**性能问题包括多个方面,例如响应不灵敏,吞吐率过低,资源消耗过高等。在多线程程序中,当线程调度器临时挂起活跃线程并转而运行另一个线程时,就会频繁出现上下文切换操作(Context Switch),这种操作会导致 CPU 时间更多的花在线程调度上而非线程的运行上。

五. 线程无处不在

在 Java 中,一个应用程序对应着一个JVM实例(JVM进程)。Java采用的是单线程编程模型**,即在我们自己的程序中如果没有主动创建线程的话,只会创建一个线程,通常称为主线程。但是要注意,虽然只有一个线程来执行任务,不代表JVM中只有一个线程,JVM实例在创建的时候,同时会创建很多其他的线程(比如垃圾收集器线程)。**由于Java采用的是单线程编程模型,因此在进行UI编程时要注意将耗时的操作放在子线程中进行,以避免阻塞主线程(在UI编程时,主线程即UI线程,用来处理用户的交互事件)。

public class Test {
    public static void main(String[] args) {
        // 获取运行当前代码的线程的名字
        String curThreadName = Thread.currentThread().getName(); 
        System.out.println(curThreadName);
    }
}/* Output: main *///:~

六. 小结

  • 进程是对运行时程序的封装,可以保存程序的运行状态,实现操作系统的并发;
  • 线程是进程的子任务,保证程序的实时性;
  • 进程是操作系统资源的分配单位,线程是CPU调度的基本单位;
  • 进程让操作系统的并发性成为可能,而线程让进程的内部并发成为可能。

引用

《Java 并发编程实战》
《Java 多线程编程核心技术》
Java并发编程:进程和线程之由来

相关文章

最新文章

更多