初始多线程

时间:2022-10-06 11:07:35

一、基本概念

1.1 应用程序

以 Windows 为例,一个拓展名为 .exe 的文件就是一个应用程序,应用程序是能够双击运行的。

1.2 进程

应用程序运行起来就创建了一个进程,即进程就是运行起来的应用程序;如电脑上运行的 Edge、Typora、PotPlayer 等。

初始多线程

进程的特点:

  1. 一个进程至少包含一个线程(主线程,main)。
  2. 可以包含多个线程(主线程+若干子线程)。
  3. 所有线程共享进程的资源。

1.3 线程

1.3.1 线程概念

我们知道,一个进程指的是一个正在执行的应用程序;而线程则是执行进程中的某个具体任务,比如一段程序、一个函数等。

进程想要执行任务就需要依赖线程;换句话说,线程就是进程中的最小执行单位,并且一个进程中至少有一个线程(主线程)。

1.3.2 主线程

  1. 每个进程都有一个主线程,这个主线程是唯一的。
  2. 当你运行了一个应用程序产生了一个进程后,这个主线程就随着这个进程默默地启动起来了。
  3. 主线程的生命周期与进程的生命周期相同,它俩同时存在、同时结束,是唇齿相依的关系。
  4. 一个进程只能有一个主线程,就像一个项目中只能有一个 main 函数一样。

1.4 进程和线程的关系

线程和进程之间的关系,类似于工厂和工人之间的关系:

  • 进程好比是工厂,线程就如同工厂中的工人。
  • 一个工厂可以容纳多个工人,工厂负责为所有工人提供必要的资源(电力、产品原料、食堂、厕所等),所有工人共享这些资源。
  • 每个工人负责完成一项具体的任务,他们相互配合,共同保证整个工厂的平稳运行。

进程仅负责为各个线程提供所需的资源,真正执行任务的是线程,而不是进程。

二、多线程概念

提到多线程这里要说两个概念,就是串行和并行,搞清楚这个,我们才能更好地理解多线程。

2.1 串行和并行

2.1.1 串行

所谓串行,其实是相对于单条线程来执行多个任务来说的,我们就拿下载文件来举个例子:当我们下载多个文件时,在串行中它是按照一定的顺序去进行下载的,也就是说,必须等下载完 A 之后才能开始下载 B;它们在时间上是不可能发生重叠的。

初始多线程

2.1.2 并行

下载多个文件,多个文件同时进行下载;这里是严格意义上的,在同一时刻发生的,并行在时间上是重叠的。

初始多线程

2.2 多线程

了解了串行和并行这两个概念之后,我们再来说说什么是多线程。举个例子,我们打开腾讯管家,腾讯管家本身就是一个应用程序,也就是说它就是一个进程,它里面有很多的功能,我们可以看下图,能查杀病毒、清理垃圾、电脑加速等众多功能:

初始多线程

按照单线程来说,无论你想要清理垃圾、还是要病毒查杀,那么你必须先做完其中的一件事,才能做下一件事,这里面是有一个执行顺序的。如果是多线程的话,我们其实在清理垃圾的时候,还可以进行查杀病毒、电脑加速等等其他的操作,这个是严格意义上的同一时刻发生的,没有执行上的先后顺序。

所谓多线程,即一个进程中拥有多个线程(≥2,主线程+若干子线程),线程之间相互协作、共同执行一个应用程序。

三、多线程编程

我们通常将以「多线程」方式编写的程序称为「多线程程序」,将编写多线程程序的过程称为「多线程编程」,将拥有多个线程的进程称为「多线程进程」。

PS:以下代码是在 Linux 下运行的。

3.1 pthread_t

定义:typedef unsigned long int pthread_t;

功能:用于声明线程ID,是一个线程标识符。

3.2 pthread_create()

3.2.1 函数介绍

函数原型:int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

头 文 件:#include <pthread.h>

功能介绍:用来创建一个线程

参数介绍

  1. 第一个参数为指向线程标识符的指针
  2. 第二个参数用来设置线程属性,一般置为 NULL,表示使用默认属性
  3. 第三个参数是线程执行的函数,返回值为 void *
  4. 最后一个参数是线程执行函数的参数

返 回 值

  • 当创建线程成功时,函数返回0
  • 若不为 0 则说明创建线程失败,常见的错误返回代码为 EAGAIN 和 EINVAL:
    • 前者表示系统限制创建新的线程,例如线程数目过多了
    • 后者表示第二个参数代表的线程属性值非法

3.2.2 牛刀小试

下面我们通过代码来深入理解如何创建一个子线程。

#include <stdio.h>
#include <pthread.h>
#include <errno.h>

/* 线程执行函数 */
void *func(void *arg)
{
    int i;
    for (i = 1; i <= 10; i++) //该函数执行动作:打印 10 次 func
    {
        printf("func[%d]\n", i);
    }
    return NULL;
}

int main()
{
    printf("主线程开始运行\n");

    pthread_t th; //定义一个线程标识符

    /* 使用默认属性创建线程,该线程执行 func 函数 */
    if (0 != pthread_create(&th, NULL, func, NULL))
    {
        /* 输出错误日志并打印相应的错误码 */
        printf("fail, errno[%d, %s], \n", errno, strerror(errno));
    }

    /* 阻塞主线程,不然,主线程马上结束,从而使创建的线程没有机会开始执行就结束了 */
    sleep(2);
    
    printf("主线程结束运行\n");
    return 0;
}

注意:编写 Linux 下的多线程程序时,需要使用头文件pthread.h,连接时需要使用静态库 libpthread.a。

运行结果如下:

初始多线程

上述代码中,我们通过在 main 中添加 sleep 来阻塞主线程,以此保证子线程可以正常运行并终止;但通过 sleep 的方式阻塞主线程多少会影响程序效率,所以我们需要换一种方式来阻塞主线程。

3.3 pthread_join()

3.3.1 小栗子

在讲解 pthread_join 前,我们先来通过一个小栗子初步体验一下为何需要 pthread_join。

场景 1

在简单的程序中一般只需要一个线程就可以搞定,也就是主线程:

int main()
{
    printf("主线程开始运行\n");

    return 0;
}

现在假设我要做一个比较耗时的工作,从一个服务器下载一个视频并进行处理,那么我的代码会变成:

int main()
{
    printf("主线程开始运行\n");
    download(); // 下载视频到本地
    process();  // 视频处理
    
    return 0;
}

场景 2

如果我需要下载两个视频素材,一起在本地进行处理,也很简单:

int main()
{
    printf("主线程开始运行\n");
    download1();    //下载视频 1
    download2();    //下载视频 2
    process();      //处理视频 1、2

    return 0;
}

本身这么做完全没有问题,可是就是有点浪费时间,如果两个视频能够同时下载就好了,这时候线程就派上了用场。

#include <stdio.h>
#include <pthread.h>

void *download1(void *arg)
{
    puts("子线程开始下载第一个视频...");
    sleep(6);  // 耗时 6s
    puts("第一个视频下载完成");
}
void *download2(void *arg)
{
    puts("主线程开始下载第二个视频...");
    sleep(10);  // 耗时 10s
    puts("第二个视频下载完成");

    return NULL;
}
void process()
{
    puts("开始处理两个视频...");
    sleep(3);  // 耗时 3s
    puts("处理完成");
}
int main()
{
    printf("主线程开始运行\n");

    pthread_t th;
    pthread_create(&th, NULL, download1, NULL);     // 子线程下载视频 1

    download2(NULL);                                // 主线程下载视频 2
    
    process();//处理视频1、2

    return 0;
}

主线程叫来了 th 这个线程去下载「视频 1」,自己去下载「视频 2」;减轻了自己的工作量也缩短了时间。

通过download函数的对比,可以发现,两个视频同时下载肯定是「视频 1」先下载完,这样在主线程下载完「视频 2」的时候,「视频 1」已经准备好了,后面就可以一起进行处理,这没什么问题。

但是万一「视频 1」的下载时间比「视频 2」的时间长呢(比如下载「视频 2」仅需要耗费 3s 的时间)?当「视频 2」下载完成了,但此时子线程 th 还没干完活,本地还没有「视频 1」,那么接下来处理的时候肯定会有问题,或者说接下来不能直接进行处理,要等 th 干完活后,主线程中的process函数才能去处理这两个视频。

在这种场景下就用到了pthread_join()这个函数。

3.3.2 pthread_join介绍

函数原型:int pthread_join(pthread_t thread, void **retval);

头 文 件:#include <pthread.h>

功能介绍:用来等待一个线程的结束

参数介绍

  1. 第一个参数为被等待的线程标识符
  2. 第二个参数为一个用户定义的指针,它可以用来保存被等待线程的返回值

返 回 值

  • On success, pthread_join() returns 0
  • On error, it returns an error number

这个函数是一个线程阻塞的函数,调用它的函数将一直等待到被等待的线程结束为止,当函数返回时,被等待线程的资源被收回。

3.3.3 调用 pthread_join

下面,我们通过 pthread_join 修改一下「场景 2」的代码:

void *download1(void *arg)
{
    puts("子线程开始下载第一个视频...");
    sleep(6);  // 耗时 6s
    puts("第一个视频下载完成");
}
void *download2(void *arg)
{
    puts("主线程开始下载第二个视频...");
    sleep(3);  // 耗时 3s
    puts("第二个视频下载完成");

    return NULL;
}
int main()
{
    printf("主线程开始运行\n");

    pthread_t th;
    pthread_create(&th, NULL, download1, NULL);     // 子线程下载视频 1

    download2(NULL);                                // 主线程下载视频 2
    
    pthread_join(th, NULL);                         // 阻塞主线程,直到「视频 1」下载完成

    process();//处理视频1、2

    return 0;
}

现在下载「视频 1」需要 6s,下载「视频 2」需要 3s;当「视频 2」下载完成后要等待「视频 1」下载完成方可一起进行处理,为了实现这个目的,我们在第 24 行加入了pthread_join()

在这个场景下,我们明确两个事情:

Q1:谁调用了pthread_join函数?

  • th 这个线程对象调用了pthread_join函数,因此必须等待 th 的下载任务结束了,pthread_join()才能返回。

Q2:在哪个线程环境下调用了pthread_join函数?

  • th 是在主线程的环境下调用了pthread_join函数的,因此主线程要等待 th 的工作做完,否则主线程将一直处于阻塞状态。

这里不要搞混的是子线程 th 真正做的任务(下载「视频 1」)是在另一个线程中做的;但是 th 调用pthread_join函数的动作是在主线程环境下做的。

3.3.4 获取线程任务的返回值

子线程执行的函数在结束后可能会有返回值:

#define STRING_LEN_24 24

/* 线程执行函数 */
void *func(void *arg)
{
    int i;
    for (i = 1; i <= 10; i++) //该函数执行动作:打印 10 次 func
    {
        printf("func[%d]\n", i);
    }

    char *buf = (char *)malloc(STRING_LEN_24);
    strncpy(buf, "The child thread ends", STRING_LEN_24 - 1);

    return buf;
}

这种情况下,该如何处理呢?还记得pthread_join()函数的第二个参数吗?这个参数就是用来保存线程函数的返回值的:

int main()
{
    printf("主线程开始运行\n");

    pthread_t th;

    if (0 != pthread_create(&th, NULL, func, NULL))
    {
        printf("fail, errno[%d, %s], \n", errno, strerror(errno));
    }

    char *buf;
    pthread_join(th, (void **)&buf);
    printf("子线程返回值[%s]\n", buf);

    printf("主线程结束运行\n");
    return 0;
}

运行结果:

初始多线程

3.3.5 及时释放资源

引入一个新的概念:线程分离(detach)和非分离(join)状态。线程的分离状态决定一个线程以什么样的方式来终止自己。

在默认情况下线程是非分离状态的,这种情况下,原有的线程等待创建的线程结束。只有当pthread_join()函数返回时,创建的线程才算终止,才能释放自己占用的系统资源。也就是说,通过默认属性创建的线程必须要通过调用pthread_join()函数来释放线程资源,换句话说,非分离状态的线程一定要调用pthread_join()函数。

对于非分离状态的线程,如果不及时调用pthread_join()函数,则会导致资源泄露。下面就通过创建大量非分离状态的线程,但不调用pthread_join()函数来观察会出现什么情况。

#include <stdio.h>
#include <pthread.h>
#include <errno.h>
#include <string.h>

/* 线程函数,不作任何操作 */
void *func(void *arg)
{
    return NULL;
}

int main()
{
    int i;
    for (i = 1; ; i++)
    {
        pthread_t th;
        if (0 != pthread_create(&th, NULL, func, NULL))
        {
            printf("fail, errno[%d, %s]\n", errno, strerror(errno));
            break;
        }
        
        printf("pthread create succeed[%d]\n", i);
    }

    return 0;
}

运行结果如下:

初始多线程

通过运行结果可以看出,未及时释放线程导致内存资源耗尽,进而导致线程创建失败。

但如果在「第 23 行」添加pthread_join(th, NULL);代码,则可以避免这种情况的发生。

3.4 pthread_detach()

函数原型:int pthread_detach(pthread_t thread);

头 文 件:#include <pthread.h>

功能介绍:从状态上实现线程分离

参数介绍:线程标识符

返 回 值

  • On success, pthread_detach() returns 0
  • On error, it returns an error number

在「3.3.5 及时释放资源」时提到了两个概念:线程分离状态和线程非分离状态。默认创建的线程为非分离状态,那么如何设置线程为分离状态呢?有两种方式:

  1. 调用pthread_detach()函数。
  2. 通过pthread_create()函数的第二个参数来设置线程分离。

一般情况下,线程终止后,其终止状态一直保留到其它线程调用pthread_join()获取它的状态为止(或者进程终止被回收了)。但是线程也可以被置为 detach 状态;如果线程被设置为了分离状态,那么该线程主动与主控线程断开关系。线程结束后(不会产生僵尸线程),其退出状态不由其他线程获取,而直接自己自动释放

#include <stdio.h>
#include <pthread.h>
#include <errno.h>
#include <string.h>

/* 线程函数,不作任何操作 */
void *func(void *arg)
{
    return NULL;
}

int main()
{
    int i;
    for (i = 1; ; i++)
    {
        pthread_t th;
        if (0 != pthread_create(&th, NULL, func, NULL))
        {
            printf("fail, errno[%d, %s]\n", errno, strerror(errno));
            break;
        }
        pthread_detach(th);//使用 pthread_detach 函数实现线程分离
        printf("pthread create succeed[%d]\n", i);
    }

    return 0;
}

注意:不能对一个已经处于 detach 状态的线程调用 pthread_join(),这样的调用将返回 EINVAL 错误。

参考资料