进程:就是二进制可执行文件在计算机内存中的运行实例,可以简单理解为:一个.exe文件是个类,进程就是该类new出来的实例。

一 多进程

(一)创建进程

Unix系统在启动后,会首先运行一个名为 init 的进程,其PID 为 1。该进程是所有其他进程的父进程。

Unix操作系统通过 fork() 函数能够创建多个子进程,从而能够提升计算机资源的利用率。此时调用者称为父进程,被创造出来的进程称为子进程。

  • 每个子进程都是源自它的父进程的一个副本,它会获得父进程的数据段、堆、栈的拷贝,并与父进程共享代码段。
  • 子进程对自己副本的修改对其父进程和兄弟进程都是不可见的,反之亦然。

创建的子进程可以直接开始运行,但是也可以通过 exec() 函数来加载一个全新的程序,此时子进程会丢弃现存的程序文本段,为加载的新程序重新创建栈、数据段、堆,我们对这一个过程称为执行一个新程序。

贴士:exec并不是1个函数, 是一系列 exec 开头的函数,作用都是执行新程序。

C语言示例如下:

#include <stdio.h> 
#include <stdlib.h>
#include <unistd.h>
 
int main(){

    pid_t pid;
    int r;

    // 创建子进程
    pid = fork();                     
    if (pid == -1){                   // 发生错误
        perror("fork发生错误 ");
        exit(1);
    }
 
    // 返回值大于0时是父进程
    if(pid > 0){                        
        printf("父进程: pid = %d, ppid = %d \n", getpid(),getppid());        // 父进程执行动作
        sleep(3);                       // 父进程睡眠,防止子进程还没运行完毕,父进程却直接退出了
    }
    
    // 返回值为0的是子进程
    if(pid == 0){   

        printf("子进程: pid = %d , ppid = %d \n", getpid(),getppid());     // 子进程执行动作

        // 子进程加载一个新程序:系统自带的 echo程序,输出 hello world!
        char * execv_str[] = {"echo", "hello world!",NULL};
        int r = execv("/bin/echo", execv_str);    // 笔者的是mac,linux上为: "/usr/bin/echo"  
        if (r <0 ){
            perror("error on exec");
            exit(0);
        }
    }
    return 0; 
}

在 Go 语言中,没有直接提供 fork 系统调用的封装,而是将 fork 和 execve 合二为一,具体信息可以参见Go的os包。

package main

import (
    "fmt"
    "os"
    "time"
)

func main() {

    fmt.Println("当前进程ID:", os.Getpid())

    procAttr := &os.ProcAttr{
        Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
    }
    process, err := os.StartProcess("/bin/echo", []string{"", "hello,world!"}, procAttr)
    if err != nil {
        fmt.Println("进程启动失败:", err)
        os.Exit(2)
    } else {
        fmt.Println("子进程ID:", process.Pid)
    }

    time.Sleep(time.Second)

}

根据该方式,就可以很容运行计算机上的其他任何程序,包括自身的命令行、Java程序等等。

(二)进程调度

同一时刻只能运行一个进程,但是CPU可以在多个进程间进行来回切换,我们称之为上下文切换。

操作系统会按照调度算法为每个进程分配一定的CPU运行时间,称之为时间轮片,每个进程在运行时都会认为自己独占了CPU,如图所示:


切换进程是有代价的,因为必须保存进程的运行时状态。

(三)进程状态转换

进程在创建后,在执行过程中,其状态一直在变化。不同时代的操作系统有不同的进程模型:

  • 三态模型:运行态、就绪态、等待态
  • 五态模型:初始态、就绪态、运行态、挂起态(阻塞)、终止态

本笔记介绍五态模型。初始态是进程的准备节点,常与就绪状态结合来看,进程的状态转换图:

(四)进程运行时的问题

4.1 写时复制

父进程无法预测子进程什么时候结束,只有进程完成工作后,父进程才会调用子进程的终止态。

贴士:全盘复制父进程的数据相当低效,Linux使用写时复制(COW:Copy on Write)技术来提高进程的创建效率。

4.2 进程回收

当一个进程退出之后,进程能够回收自己的用户区的资源,但是不能回收内核空间的PCB资源,必须由它的父进程调用wait或者waitpid函数完成对子进程的回收,避免造成系统资源的浪费。

孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程,此时该进程会被系统的 init 进程领养

僵尸进程:子进程终止,但父进程未回收,子进程残留资源(PCB)于内核中,变成僵尸进程。

注意:由于僵尸进程是一个已经死亡的进程,所以不能使用kill命令将其杀死,通过杀死其父进程的方法可以消除僵尸进程,杀死其父进程后,这个僵尸进程会被init进程领养,由init进程完成对僵尸进程的回收。

(五)进程通信

5.1 进程通讯概述

Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)。

在进程间完成数据传递需要借助操作系统提供特殊的方法,如:文件、管道、信号、共享内存、消息队列、套接字、命名管道等。随着计算机的蓬勃发展,一些方法由于自身设计缺陷被淘汰或者弃用。现今常用的进程间通信方式有:

  • 管道 (使用最简单)
  • 共享映射区 (无血缘关系进程通信)
  • 信号 (开销最小)
  • 本地套接字 (最稳定)

Go支持的IPC方法有:管道、信号、socket。

5.2 管道

管道是一种最基本的IPC机制,也称匿名管道,应用于有血缘关系的进程之间,完成数据传递。调用C的pipe函数即可创建一个管道。

管道有如下特质:

  • 管道的本质是一块内核缓冲区
  • 由两个文件描述符引用,一个表示读端,一个表示写端。
  • 规定数据从管道的写端流入管道,从读端流出。
  • 当两个进程都终结的时候,管道也自动消失。
  • 管道的读端和写端默认都是阻塞的。

管道的实质是内核缓冲区,内部使用唤醒队列实现。

管道的缺陷:

  • 管道中的数据一旦被读走,便不在管道中存在,不可反复读取。
  • 数据只能在一个方向上流动,若要实现双向流动,必须使用两个管道
  • 只能在有血缘关系的进程间使用管道。

Go模拟管道的实现:

cmd1 := exec.Command("ps", "aux")
cmd2 := exec.Command("grep", "apipe")
var outputBuf1 bytes.Buffer
cmd1.Stdout = &outputBuf1
cmd1.Start()
cmd1.Wait()                // 开始阻塞
var outputBuf2 bytes.Buffer
cmd2.Stdout = &outputBuf2
cmd2.Start()
cmd2.Wait()                // 开始阻塞
fmt.Println(outputBuf2.Bytes())

当然也有一种管道称为命名管道(FIFO),它支持无血缘关系的进程之间通信。FIFO是Linux基础文件类型中的一种(文件类型为p,可通过ls -l查看文件类型)。但FIFO文件在磁盘上没有数据块,文件大小为0,仅仅用来标识内核中一条通道。进程可以打开这个文件进行read/write,实际上是在读写内核缓冲区,这样就实现了进程间通信,如图所示:

5.3 内存映射

存储映射I/O (Memory-mapped I/O) 使一个磁盘文件与存储空间中的一个缓冲区相映射。从缓冲区中取数据,就相当于读文件中的相应字节;将数据写入缓冲区,则会将数据写入文件。这样,就可在不使用read和write函数的情况下,使用地址(指针)完成I/O操作。

使用存储映射这种方法,首先应通知内核,将一个指定文件映射到存储区域中。这个映射工作可以通过mmap函数来实现。

5.4 信号

信号是IPC中唯一一种异步的通信方法,本质是用软件模拟硬件的中断机制,例如:在命令行终端按下某些快捷键,就会挂起或停止正在运行的程序。Go中的ginal包提供了相关操作。

sigRecv := make(chan os.Signal, 1)                      // 创建接收通道
sigs := []os.Signal{syscall.SIGINT, syscall.SIGQUIT}    // 创建信号类型
signal.Notify(sigRecv, sigs...)
for sig := range sigRecv {
// 循环接收通道中的信号,通道关闭后,for会立即停止    
      fmt.Println(sig)
}

5.5 socket

socket即套接字,也是一种IPC方法,与其他IPC方法不同之处在于:可以通过网络连接让多个进程建立通信并相互传递数据,这使得通信不再依赖于在同一台计算机上。

(六)进程同步

当多个子进程对同一资源进行访问时,就会产生竞态条件。比如:某一个数据,进程A对其进行执行一系列操作,但是在执行过程中,系统有可能会切换到另外一个进程B中,B也对该数据进行一系列操作,那么在两个进程中操作同一份数据时,这个数据的结果值到底按照谁的来运算呢?

原子操作:如果执行过程中操作不能中断,那么就能解决上述问题,这样的操作称为原子操作(atomic operation)。这些只能被串行化访问或执行的资源或者某段代码被称为临界区(critical section)。Go中(sync/atomic包提供了原子操作函数)。

注意:

  • 所有的系统调用都是原子操作,即不用担心它们的执行被中断!
  • 原子操作不能被中断,临界区是否可以被中断没有强制规定,只是保证了只能同时被一个访问者访问。

问题:如果一个原子操作无法结束,现在也无法中断,如何处理?

答案:内核只提供了针对二进制位和整数的原子操作(即保证细粒度),不会有上述现象。

互斥锁:
在实际开发中,原子操作并不通用,我们可以保证只有一个进程/线程在临界区,该做法称为互斥锁(exclusion principle),比如信号量是实现互斥方法的方式之一,Golang的sync包也有对互斥的支持。

(七)进程内存分配方式

操作系统会为每个进程分配一定的内存地址空间,如图所示:

上图所示的是32位系统中虚拟内存的分配方式,不同系统分配的虚拟内存是不同的,但是其数据所占区域的比例是相同的:

  • 32位:最大内存地址为232,这么多的字节数换算为G单位,即为4G。(换算为1G=1024MB=10241024KB=10241024*1024B)
  • 64位:最大内存地址为264,这么多的字节数换算为G单位,数值过大,不便图示

在多进程编程的并发模型中,每次fork一个子进程,都代表新创建了一个完整的上述内存地址空间,如图所示:

二 多线程

(一)创建线程

一个进程内部可以创建多个线程,如图所示

(二)理解线程

从创建线程的图示可以看出:线程可以视为某个进程内部的控制流。

线程:操作系统基于进程开启的轻量级进程,
线程是操作系统最小的调度执行单位(即cpu分配时间轮片的对象)

线程不能独立于进程而存在,其生命周期不可能逾越其所属的进程生命周期,与进程不同,线程不存在父子级别关系,同一进程中的任意2个线程之间的关系是平等的。
一个进程内部的线程包括:

  • 主线程:必定拥有,因为进程必须有一个控制流持续运行,该线程随着进程的启动而创建
  • 其他线程:不一定拥有,由主线程或者其他线程创建(C语言调用pthread_create函数)

综上我们可以得出:

线程与进程一样拥有独立的PCB,但是没有独立的地址空间,即线程之间共享了地址空间。这样也让线程之间无需IPC,直接就能通信!!

进程的大多数资源会被其内部的线程所共享,如:代码段、数据段、堆、信号处理函数、当前进程持有的文件描述符等。所以,同一进程中的多个线程运行的一定是同一个程序,只不过具体的控制流和执行的函数可能不同。也正因如此,同一进程内的多线程共享数据变得很轻松,创建新线程也无需再复制资源了。

虽然线程带来了通信的便利,如果同一空间的中多个线程同时去使用同一个数据,就会造成资源竞争问题,这是计算机编程中最复杂的问题之一。

(三)线程标识

每个线程也有属于自己的ID,称为TID,只在其所属的进程范围内唯一。

注意:Linux中的线程ID在系统范围内也是唯一的,且线程不存在后,该ID可被其他线程复用。

(四)线程调度

线程之间不存在类似进程的树形关系,任何线程都可以对同一进程的其他线程进行有限的管理。

调度器会把事件划分为极小的时间片,并把这些时间片分配给不同的线程,以使众多线程都有机会在CPU上运行,也造成了我们多线程被并行运行的幻觉。

(五)线程的应用

对于多线程并发模型的web服务器,如果需要同时处理多个请求,当请求到达时,web 服务器会创建一个线程,或者从线程池中获取一个线程,然后将请求来委派给线程来实现并发。

(六)线程同步

6.1 同步的概念

由于多进程、多线程、协程等都可以抢占共享资源,我们就必须保证他们访问时数据的一致性,这种保持数据内容一致的机制称为同步

多个控制流操作一个共享资源的情况,都需要同步!!

一般情况下,只要让共享区域的操作串行化,就可以实现同步,这种实现了串行化的共享区域称为临界区

这里主要研究线程同步的方式,包括:

  • 互斥量
  • 条件变量
  • 原子操作

6.2 互斥量

互斥(mutex):在同一时刻,只允许一个线程处于临界区内。

线程将对象锁定后,才能进入临界区,否则线程就会阻塞,这个对象我们称之为互斥对象或者互斥量。

由此可知,互斥量有已锁定、未锁定两种状态,且一旦被锁,则不能再次锁定,只有解锁后才能再次锁定(即不允许别的线程二次加锁)。多个线程为了能够访问临界区,将会争夺锁的所有权。

线程在离开临界区的时候,必须对互斥量进行解锁,此时其他想进入该临界区的线程将会被唤醒再次争夺锁。

如果不同的临界区中包含了对同一个共享资源的同一种操作,此时会产生死锁。

解决死锁的办法有两种:

  • 试锁定-回退:操作系统的线程库中提供了该功能。在执行一个代码块时,如果需要先后锁定多个互斥量,成功锁定其中一个互斥量后应该使用试锁定的方法来锁定后续互斥量,如果后续任一互斥量锁定失败,则解锁刚才被锁的互斥量,重新进行争夺锁尝试。

    • 注意:多个互斥量被成功加锁后,解锁顺序和加锁顺序相反,这样可以减少回退次数。
  • 固定顺序锁定:举例,线程A和线程B总是先锁定互斥量1,再锁定互斥量2,那么就不会产生死锁。

第一种方案更加有效,但是程序变得复杂了,后一种方法简单实用,但是因为存在固定顺序,降低了程序的灵活性。

6.3 条件变量

互斥量有时候也不能完美解决问题,比如最常见的生产消费模型中:

  • 数据队列:具备一定大小的空间,用于存储生产的数据
  • 生产者线程:向数据队列不断的添加数据
  • 消费者线程:向数据队列不断的取出数据
    由于生产者线程和消费者线程都会对数据队列进行并发访问,那么我们肯定会为数据队列进行加锁操作,以实现同步。

此时如果生产者线程获得互斥量,发现数据队列已满,无法添加新数据,生产者线程就可能在临界区一直等待,直到有空闲区间。这种做法明显是错误的,因为该线程一直阻塞在临界区,直接影响了其他消费者线程的使用!生产者线程应该在发现没有空闲区间时直接解锁退出。

同样的,消费者线程在获取锁后,如果发现数据队列为空,则也会一直等待,这都是不合理的,应该发现为空后直接解锁。

引入条件变量,与互斥量配合使用,可以解决上述问题。

条件变量:条件变量一般与互斥量组合使用,在对应的共享数据状态发生变化时,通知其他被阻塞线程。

条件变量有三种操作:

  • 等待通知(wait):如果当前数据状态不满足条件,则解锁与该条件变量绑定在一起的互斥量,然后阻塞当前线程,直到收到该条件变量发来的通知
  • 单发通知(signal):让条件变量向至少一个正在等待它通知的线程发送通知,以表示共享数据状态发生了改变
  • 广播通知(broadcast):给等待通知的所有线程发送通知

6.4 原子操作

原子操作的执行过程不能被中断,因为此时CPU不会去执行其他对该值进行的操作,这也能有效的解决一部分竞争问题。

最后修改:2020 年 08 月 20 日 02 : 53 PM
如果觉得我的文章对你有用,请随意赞赏