多线程编程基础概念

本文主要学习了程序设计中的进程和线程等概念,并由此衍生出并行 VS 并发,同步和异步调用,多线程同步与互斥、线程池等概念

什么是进程?

  • 进程是从就绪状态分派并在 CPU 中调度执行的程序。即 PCB(进程表)持有过程的概念
  • 在面向线程设计的系统中,进程本身不是基本运行单位,而是线程的容器,进程的所有线程共享全局变量(存储在堆中)和程序代码
  • 进程可以具有以下状态:新建、就绪、运行、等待、终止和暂停

什么是线程?

  • 线程是操作系统能够进行 CPU 调度的最小单位,它被包含在进程之中,一个进程可包含单个或者多个线程
  • 线程不是一个计算机硬件的功能,而是操作系统提供的一种逻辑功能,线程本质上是进程中一段并发运行的代码,所以线程需要操作系统投入 CPU 资源来运行和调度
  • 与进程相比,线程终止所需的时间更少,但与进程不同的是,线程不会隔离
  • 线程具有三种状态:运行、就绪和阻塞

进程与线程关系与区别?

  • 程序可产生多个进程: 用户下达运行程序的命令后,就会产生进程。同一程序可产生多个进程(一对多关系),以允许同时有多位用户运行同一程序,却不会相冲突
  • 线程可以理解为子进程: 进程是计算机管理运行程序的一种方式,一个进程下可包含一个或者多个线程
  • 多进程的程序要比多线程的程序健壮:但在进程切换时,耗费资源较大,效率要差一些
  • 进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响

程序、进程、线程有什么关系?

  • 每个应用程序至少有一个进程,而每个进程至少有一个主线程,除了主线程外,一个进程中还可以创建多个线程
  • 主线程就是以 main 函数作为入口函数的线程
  • 非主线程都需要一个入口函数,入口函数返回退出,该线程也会退出

在多进程中,进程通信方式?

  • 管道(pipe):用于具有亲缘关系的父子进程间的通信
  • 信号(signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一致的
  • 消息队列(message queue):消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点,具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息
  • 共享内存(shared memory):可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等
  • 信号量(semaphore):主要作为进程之间及同一种进程的不同线程之间得同步和互斥手段
  • 套接字(socket):这是一种更为一般得进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛

什么是并发?

  • 在单核 CPU 中,通过线程之间的频繁切换来实现的。这称为上下文切换。在上下文切换中,每当发生任何中断(由于 I/O 或手动设置)时,都会保存一个线程的状态并加载另一个线程的状态。上下文切换发生得非常频繁,以至于所有线程似乎都在并行运行(这称为多任务处理)

什么是并行?

  • 当一个 CPU 执行一个线程时,另一个 CPU 可以执行另一个线程,两个线程互不抢占 CPU 资源,可以同时进行,这种方式我们称之为并行(Parallel)
  • 当系统有一个以上 CPU 时,则线程的操作有可能非并发

并发和并行有什么区别?

  • 在单核时代,多个线程是并发的,在一个时间段内轮流执行
  • 在多核时代,多个线程可以实现真正的并行,在多核上真正独立的并行执行

什么是同步调用?

  • 同步,就是调用某个东西是,调用方得等待这个调用返回结果才能继续往后执行

什么是异步调用?

  • 异步,和同步相反 调用方不会理解得到结果,而是在调用发出后调用者可用继续执行后续操作,被调用者通过状体来通知调用者,或者通过回掉函数来处理这个调用

异步调用通知调用者的 3 种方式?

  • 监听被调用者的状态(轮询),调用者需要每隔一定时间检查一次,效率会很低
  • 当被调用者执行完成后,发出通知告知调用者,无需消耗太多性能
  • 与通知类似,当被调用者执行完成后,会调用调用者提供的回调函数

多线程和异步操作的异同?

  • 异步是目的,而多线程是实现这个目的的方法
  • 多线程和异步操作两者都可以达到避免调用线程阻塞的目的
  • 异步操作无须额外的线程负担,并且使用回调的方式进行处理,在设计良好的情况下,处理函数可以不必使用共享变量 ,减少了死锁的可能,单编写异步操作的复杂程度较高
  • 线程中的处理程序依然是顺序执行,符合普通人的思维习惯,所以编程简单

什么是阻塞?

  • 指在不能立刻得到结果之前,该调用不会阻塞当前线程

什么是非阻塞?

  • 指调用结果返回之前,当前线程会被挂起

同步异步与阻塞非阻塞的关系?

  • 同步和异步关注的是消息通信机制
  • 阻塞和非阻塞这两个概念与程序(线程)等待消息通知(无所谓同步或者异步)时的状态有关
  • 对于同步调用来说,很多时候当前线程还是激活的状态,只是从逻辑上当前函数没有返回而已,即同步等待时什么都不干,白白占用着资源

线程数量该如何设计?

  • 当为了分离关注点而使用多线程时,设计线程的数量的依据,不再是依赖于 CPU 中的可用内核的数量,而是依据概念上的设计(依据功能的划分)

在多线程中,什么是同步、互斥?

  • 同步:指维护任务片段的先后顺序,这是因为线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。例如:两个线程 A 和 B 在运行过程中协同步调,按预定的先后次序运行,比如 A 任务的运行依赖于 B 任务产生的数据
  • 互斥:保证资源同一时刻只能被一个进程使用,当有若干个线程都要使用某一共享资源时,任何时刻允许有限的线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。例如:两个线程 A 和 B 在运行过程中共享同一变量,但为了保持变量的一致性,如果 A 占有了该资源则 B 需要等待 A 释放才行,如果 B 占有了该资源需要等待 B 释放才行,将需要互斥执行的代码称为临界区
  • 互斥解决了「多进程/线程」对临界区使用的问题,但是它没有解决「多进程/线程」协同工作的问题,在多线程里,每个线程一定是顺序执行的,它们各自独立,以不可预知的速度向前推进,但有时候我们希望多个线程能密切合作,以实现一个共同的任务。所谓同步,就是「多进程/线程间」在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为「进程/线程」同步
  • 同步是一种更为复杂的互斥,而互斥是一种特殊的同步。也就是说互斥是两个线程之间不可以同时运行,他们会相互排斥,必须等待一个线程运行完毕,另一个才能运行,而同步也是不能同时运行,但他是必须要按照某种次序来运行相应的线程(也是一种互斥)

在多线程中,同步和互斥有几种实现方法?

  • 用户模式:原子操作(例如一个单一的全局变量),临界区
  • 内核模式:利用系统内核对象的单一性来进行同步,例如事件,信号量,互斥量

在多进程中,什么是临界区、互斥量、信号量、事件?

  • 临界区(Critical section):通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问
  • 互斥量(mutex):为协调共同对一个共享资源的单独访问而设计的
  • 信号量(semaphore):为控制一个具有有限数量用户资源而设计
  • 事 件(Event):用来通知线程有一些事件已发生,从而启动后继任务的开始

在多进程中,什么是死锁?

  • 多个线程争夺共享资源导致每个线程都不能取得自己所需的全部资源,从而程序无法向下执行
  • 有四个原因导致死锁,1)互斥; 2)请求并保持(进程在请求资源时,不释放自己已经占有的资源); 3)不剥夺(进程已经获得的资源,在进程使用完前,不能强制剥夺); 3)循环等待(进程间形成环状的资源循环等待关系)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    #include<process.h>
    #include<windows.h>
    #include<stdio.h>
    CRITICAL_SECTION cs1;
    CRITICAL_SECTION cs2;
    void ThreadFun1(void* param);
    void ThreadFun2(void* param);
    int main() // 以下是循环等待的过程
    {
    InitializeCriticalSection(&cs1);
    InitializeCriticalSection(&cs2);
    uintptr_t t1 = _beginthread(ThreadFun1, 0, "A");
    uintptr_t t2 = _beginthread(ThreadFun2, 0, "B");
    //A在1区域,等着B从2区域出来
    //B在2区域,等着A从1区域出来
    //相互等待中,发生死锁
    HANDLE hArr[] = { (HANDLE)t1, (HANDLE)t2 };
    WaitForMultipleObjects(2, hArr, true, INFINITE);
    return 0;
    }
    void ThreadFun1(void* param)
    {
    char* name = (char*)param;
    //进入1区域之后,任何人无法进1区域
    EnterCriticalSection(&cs1);
    printf("%s进入了1区域,休息3秒\n", name);
    Sleep(3000);
    printf("%s想进入2区域\n", name);
    // 想进入2区域,但是2区域被线程2占用,循环等待
    EnterCriticalSection(&cs2);
    printf("%s进入2区域\n", name);
    LeaveCriticalSection(&cs2);
    LeaveCriticalSection(&cs1);
    }
    void ThreadFun2(void* param)
    {
    char* name = (char*)param;
    //进入2区域之后,任何人无法进2区域
    EnterCriticalSection(&cs2);
    printf("%s进入了2区域,休息3秒\n", name);
    Sleep(3000);
    printf("%s想进入1区域\n", name);
    // 想进入1区域,但是1区域被线程1占用,循环等待
    EnterCriticalSection(&cs1);
    printf("%s进入1区域\n", name);
    LeaveCriticalSection(&cs1);
    LeaveCriticalSection(&cs2);
    }

为什么要使用线程池?

  • 不使用线程池时
    • 经典多线程流程为: 创建线程 -> 由该线程执行任务 -> 任务执行完毕后销毁线程。即使需要使用到大量线程,每个线程都要按照这个流程来创建、执行与销毁
    • 虽然创建与销毁线程消耗的时间 远小于 线程执行的时间,但是对于需要频繁创建大量线程的任务,创建与销毁线程 所占用的时间与 CPU 资源 也会有很大占比
  • 使用线程池后
    • 程序启动后,预先创建一定数量的线程放入空闲队列中,这些线程都是处于阻塞状态,基本不消耗 CPU,只占用较小的内存空间
    • 接收到任务后,任务被挂在任务队列,线程池选择一个空闲线程来执行此任务
    • 任务执行完毕后,不销毁线程,线程继续保持在池中等待下一次的任务
    • 在并发的任务很多时候,无法为每个任务指定一个线程(线程不够分),使用线程池可以将提交的任务挂在任务队列上,等到池中有空闲线程时就可以为该任务指定线程