Java多线程笔记(零):进程、线程与通用概念

 约 13 分钟

前言

不积跬步,无以至千里;不积小流,无以成江海。在学习Java多线程相关的知识前,我们首先需要去了解一点操作系统的进程、线程以及相关的基础概念。

进程

通常,我们把一个程序的执行称为一个进程。反过来讲,进程用于描述程序的执行过程。因此,程序和进程是一对概念,它们分別描述了一个程序的静态和动态特征:除此之外,进程还操作系统进行资源分配的一个基本单位。

进程的衍生

进程使用fork系统调用来创建。父进程调用fork创建子进程。每个子进程都是源自它的父进程的一个副本,它会获得父进程的数据段、堆和栈的副本,并与父进程共享代码段。每一份副本都是独立的,子进程对属于它的副本的修改对其父进程和兄弟进程(同父进程)都是不可见的,反之亦然。全盘复制父进程的数据是一种相当低效的做法。 Linux操作系统内核使用写时复制(Copy on Write,常简称为COW)等技术来提高进程创建的效率。当然,刚创建的子进程也可以通过系统调用exec把一个新的程序加载到己的内存中,而原先在内存中的数据段、堆、栈以及代码段就会被替换掉,在这之后,子进程执行的就会是那个刚刚加载进来的新程序。

父进程被如果优先于子进程结束,那么子进程就会被原来父进程的父进程“收养”。

为了管理进程,内核必须对每个进程的数据和行为进行详细的记录,包括进程的优先级、状态、虚拟地址范围以及各种访问权限等等。更具体地说,这些信息都会被记在每个进程的进程描述符中。进程描述符并不是一个简单的符号,而是一个非常复杂的数据结构。保存在进程描述符中的进程ID (常称为PID )是进程在操作系统中的唯一标识,其中进程ID为1的进程就是之前提到的内核启动进程。进程id是一个非负整数且总是顺序的编号,新创建的进程ID总是前一个进程ID递增的结果。此外,进程ID也可以重复使用。当进程ID达到其最大限值时,内核会从头开始查找闲置的进程ID并使用M先找到的那一个作为新进程的ID。另外,进程描述符中还会包含当前进程的父进程的ID (常称为PPID )。

进程间的同步

如果多个进程之间需要协作完成任务,那么进程间通信的方式就是需要重点考虑的事项之一。这种通信叫做IPC(Inter-Process Communication)。那么在Linux中,从处理机制的角度看,可以分为三大类方法:

  1. 基于通信的IPC
  2. 基于信号的IPC
  3. 基于同步的IPC

通信IPC

  • 以数据为传送手段的IPC

    • 管道(pipe):用于传输字节流
    • 消息队列(message queue):用来传输结构化的对象
  • 以共享内存为手段的IPC

    • 共享内存区(share memory):最快的IPC方法

信号IPC

  • 操作系统的信号(signal)机制:唯一一种异步IPC方法。通过kill -l查看。

同步IPC

  • 信号量(semaphore)

进程的状态

在Linux中,每个进程在每个时刻只会有一种状态,分别有以下六种

可运行状态(TASK_RUNNING)

该进程立刻或正在CPU上运行。但是运行的时期是不确定的,由进程调度来决定。

可中断的睡眠状态(TASK_INTERRUPTABLE)

如果一个进程正在等待某个事件到来时,会进入此状态。这样的进程会被放入对应的等待队列中。当事件发生时,对应的等待队列中的一个或多个进程就会被唤醒。

不可中断的睡眠状态(TASK_UNINTERRUPTIBLE)

此种状态可与中断的睡眠状态的唯一区别是它不可被打断。这意味着此种状态的进程不会对任何信号作出响应。更确切地讲,发送给此状态的进程的信号直到它状态转出才会被传递过去。处于此状态的进程通常是在等待一个特殊的时间,比如等待同步的IO操作完成。

暂停状态(TASK_STOPPED或TASK_TRACED)或跟踪状态

向进程发送SIGSTOP信号,就会使该进程转入暂停状态,除非该进程正处于不可中断的睡眠状态。

向正处于暂停的进程发送SIGCONT信号,会使用该进程转向可运行状态。处于该状态的进程会暂停,并等待另一个进程(跟踪它的那个进程)对它进行操作。例如,我们使用调试工具GDB在某个程序中设置一个断点,而后对应的进程运行到该断点处就会停下来。这时,该进程就处于跟踪状态。跟踪状态与暂停状态非常类似。但是,向处于跟踪状态的进程发送SIGCONT信号并不能使它回复。只有当调试进程进行了相应的系统调用或退出后,它才能够恢复。

僵尸状态(TASK_DEAD-EXIT_ZOMBIE)

处于此状态的进程即将结束运行,该进程占用的绝大多数资源也都已经被回收,不过还有一些信息未还是拿出,比如退出码以及一些统计信息。之所以保留这些信息,主要是考虑到该进程的父进程可能需要它们。由于此时的进程主体已经被删除而只留下一个空壳,故此状态才被称为僵尸状态。

退出状态(TASK_DEAD-EXIT_DEAD)

在进程退出的过程中,有可能连退出码和统计信息都不需要保留。造成这种情况的原因可能是显示地让该进程的父进程忽略掉SIGCHLD信号(当一个进程消亡的时候,内核会给其父进程发送SIGCHLD信号以告知此情况),也可能是该进程已经被分离(分离即让子进程和父进程分别独立运行)。分离后的子程序将不会再使用和执行与父进程共享代码段中的指令,而是加载并运行一个全新的程序。在这些情况下,该进程处于退出的时候就不会转入僵尸状态,而会直接转入退出状态。处于退出状态的进程会立即被干净利落地结束掉,它占用的系统资源也会被操作系统自动回收。

内核为每个用户进程分配的是虚拟内存而不是物理内存。同时,内核会把进程的虚拟内存划分为若干页(page),而物理内存单元的划分由CPU负责。一个物理内存单元被称为一个页框(page freame)。不同进程的大多数页都会与不同的页框相对应。对应的时候那就是共享内存了。

线程

线程可以视为进程中的控制流。一个进程至少包含一个线程,因为其他至少会有一个控制流持续运行。因而,一个进程的第一个线程会随着这个进程的启动而创建,这个线程被称为该进程的主线程。当然,一个进程可以包含多个线程。这些线程都是由当前线程中已经存在的线程创建出来的,创建的方法就是调用系统调用(pthread_create)。拥有多个线程的进程可以并发执行多个任务,并且即时某个或某些任务被阻塞,也不会影响其他任务执行,这可以大大改善程序的响应时间和吞吐量。另一方面,线程不可能独立于进程存在。它的生命周期不可能逾越所属进程的生命周期。

一个进程中的所有线程都拥有自己线程栈,并以此存储自己的私有数据。这些线程的线程栈都包含在其所属进程的虚拟内存地址中。不过要注意,一个进程中的很多资源都会被其中的所有线程共享,这些被线程共享的资源包含当前进程所持有文件描述符,等等。正因为如此,同一个进程的多个线程运行的一定是同一个程序,只不过具体的控制流程的执行函数可能有所不同。在同一个进程的多个线程之间共享数据也是一件非常轻松和自然的事情。另外,创建一个新线程,也不会像创建一个新进程那样耗时费力,因为在其所属进程的虚拟内存地址中存储的代码、数据和资源都不需要被复制。

另外,操作系统和提供了一定的系统调用用于管理当前进程中的线程。

线程的标识

和进程一样,每个线程都有自己的ID(由内核分配),叫做线程ID或者TID。但是在操作系统范围内不唯一,在所属进程的范围内唯一。

线程的控制

任何一个线程都可以同一线程中的其他线程进行有限管理,如下:

创建线程

主线程在其所属进程启动时创建。其他线程可以通过别的线程用pthread_create来创建——要传入新线程将要执行的函数以及传入该函数的参数值。在创建成功的时候,该函数会返回线程的TID。

终止线程

线程可以通过多种方式来终结同一个进程中的其他线程。其他一种方式就是调用系统调用pthread_cancel,其作用是取消掉给定线程ID代表的那个线程。更确切地讲,它会向目标线程发送一个请求,要求它立刻终止执行。但是该函数只是发送请求并即可返回。但是,该函数只是发送请求并立刻返回,而不会等待目标线程对该请求做出响应。至于目标线程什么时候对此做出线程、怎么样的响应,则取决与另外的因素(比如线程目标的取消状态及类型)。在默认情况下,目标线程总是会接受线程取消请求,不过等到时机成熟(执行到某个取消点)的时候,目标线程才会响应线程的取消请求。

连接已终止的线程

此操作由系统调用pthread_join来执行,该函数会一直等待与给定的线程ID对应的那个线程终止,并把线程执行的pthread_create函数的返回值告知调用线程。如果目标线程已经处于终止状态,那么该函数会立即返回。这就像是把调用线程放置在了目标线程的后面,当目标线程把线程控制权交出时,调用线程会接过流程控制权并继续执行pthread_join函数调用之后的代码。这也把这一操作称为连接的缘由之一。实际上,如果一个线程可被连接,那么在它终止之前就必须连接,否则就会变成一个僵尸线程。僵尸线程不但会导致系统资源浪费,还会无意义减少其进程的可创建线程数量。

分离线程

将一个线程分离后那么它将变得不可连接。而在默认情况下,一个线程总是可以被连接的。分离操作的另一个作用是让操作系统内核在目标线程终止时自行进行清理和销毁工作。注意,分离操作是不可逆的。也就是说,我们无法使一个不可连接的线程变回可连接的状态。不过,对于一个已处于分离状态的线程,执行终止操作仍然会起作用。分离操作由系统调用pthread_detach来执行,它接受一个代表了线程ID的参数值。

一个线程对自身也可以进行两种控制:终止和分离。线程终止自身的方式有很多种。在线程执行的start函数中执行return语句,会使该线程随着start函数的结束而终止。需要注意的是,如果在主线程中执行了return语句,那么当前进程中的所有线程都会终止。另外,在任意线程中调用系统调用exit也会达到这种效果。还有一种终止自身的方式就是显示调用pthread_exit。

而分离pthread_detach函数则是传入自己的TID。

多线程与多进程

在多个线程之间交换线程是非常简单和自然的事,而在多个进程之间只能通过一些额外的手段(比如管道、消息队列、信号量和共享内存区)传递数据。显然,使用这些额外手段会增加开发成本。不过,线程间交换数据虽然简单但却由于可能发生竞态条件而不得不使用一些同步工具(比如互斥量和条件变量)加以保护。这些与业务逻辑无关的代码会增加程序的复杂度,尤其在使用不当的情况下还会引起灾难。

互斥量可以理解为我们常见的锁。而条件变量所做的就是保证线程间共享的数据状态改变时通知到其他因此而被阻塞的线程。条件变量总是与互斥量组合使用。当线程成功锁定互斥量并访问到共享数据时,共享数据的状态并不一定满足它的要求。下面就通过一个示例来描述条件变量的使用场景。

通用概念

原子操作

执行过程不能中断的操作称为原子操作(atomic operation)。必须一个单一的汇编指令表示,而且需要得到芯片级别的支持。

临界区

临界区(critical section)用来表示一种公共资源或者共享数据,可以被多个线程使用。但是每一次,只有一个线程可以使它,一旦临界区资源被占用,其他线程要想使用资源,就必须等待,即串行化访问或执行。

互斥

保证只有一个进程或线程在临界区内的做法只有一个——互斥(mutual exclusion。简称 mutex)。

同步和异步

描述的是用户线程与内核的交互方式:

  • 同步(Synchrounous)是指用户线程发起 I/O 请求后需要等待或者轮询内核 I/O 操作完成后才能继续执行;
  • 异步(Asynchrounous)是指用户线程发起 I/O 请求后仍继续执行,当内核 I/O 操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

阻塞和非阻塞

描述的是用户线程调用内核 I/O 操作的方式:

  • 阻塞(Blocking)是指 I/O 操作需要彻底完成后才返回到用户空间;
  • 非阻塞(Non-Blocking)是指 I/O 操作被调用后立即返回给用户一个状态值,无需等到 I/O 操作彻底完成。

一个 I/O 操作其实分成了两个步骤:

  1. 发起 I/O 请求
  2. 实际的 I/O 操作。

阻塞 I/O 和非阻塞 I/O 的区别在于第一步,发起 I/O 请求是否会被阻塞。如果阻塞直到完成那么就是传统的阻塞 I/O ,如果不阻塞,那么就是非阻塞 I/O 。 同步 I/O 和异步 I/O 的区别就在于第二个步骤是否阻塞,如果实际的 I/O 读写阻塞请求进程,那么就是同步 I/O 。

并发(Concurrency)和并行(Parallelism)

并发和并行往往被人所混淆。它们都可以表示两个或多个任务一起执行,但是偏重点有些不同。并发偏重于多个任务交替执行,而多个任务有可能还是串行。而并行则是真正意义上的“同时执行”。

ConcuarrencyAndParallelism.jpg

严格来说,并行的多个任务是真实的同时执行,而对并发来说,这个过程这是交替的,一会儿运行任务A一会儿执行任务B,系统会不停地在两者间切换。但对于外部观察者来说,即使多个任务之间是串行并发的,也会造成多任务间是并行执行的错觉。

死锁(DeadLock)、饥饿(Starvation)和活锁(Livelock)

死锁、饥饿和活锁都属于多线程的活跃性问题,如果发生上述情况,那么相关线程可能就不再活跃,也就是说它可能很难继续往下执行了。

死锁应该是最糟糕的一种情况了,虽然别的情况也没有好到哪儿去。

  • 死锁:多个线程互相等待多方释放资源而一直没有执行。
  • 饥饿:一个或多个线程因为种种原因无法获取所得的需要资源,导致一直无法执行。导致的原因往往是当前线程优先级不高导致没有资源,或某线程一直占着关键资源不放。
  • 活锁:多个线程都释放资源给别的线程使用,导致没有线程拿到资源而正常执行。
阅读 2k

推荐阅读
泊浮说
用户专栏

作者是个热爱分享交流的人,所以有了这个专栏。你的点赞是我最大的更新动力。

56 人关注
45 篇文章
专栏主页
目录