必会概念

所谓”同时”进行

在我们使用Windows的时候,可以一边看电影、一边听歌、一边写代码。。。。

但是,这所谓的”同时”,在操作系统底层可能并不是真正的意义上的”同时”。

实际上,对于单CPU的计算机来说,在CPU中,同一时间是只能干一件事儿的。为了看起来像是“同时干多件事”,Windows这种操作系统是把CPU的时间划分成长短基本相同的时间区间,即”时间片”,通过操作系统的管理,把这些时间片依次轮流地分配给各个应用使用。

由于时间片的概念,宏观上我们就感觉所有的任务都在同时运行,但实际上是任务在以非常快的速度进行切换。

并发

并发(Concurrent),在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。其中两种并发关系分别是同步和互斥

  • 互斥:进程间相互排斥的使用临界资源的现象,就叫互斥。
  • 同步(synchronous):进程之间的关系不是相互排斥临界资源的关系,而是相互依赖的关系。进一步的说明:就是前一个进程的输出作为后一个进程的输入,当第一个进程没有输出时第二个进程必须等待。具有同步关系的一组并发进程相互发送的信息称为消息或事件。(彼此有依赖关系的调用不应该同时发生,而同步就是阻止那些“同时发生”的事情)
  • 其中并发又有伪并发和真并发,伪并发是指单核处理器的并发,真并发是指多核处理器的并发

并行

并行(Parallel),当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

这里面有一个很重要的点,那就是系统要有多个CPU才会出现并行。在有多个CPU的情况下,才会出现真正意义上的『同时进行』。

并行是同时发生的多个并发事件,具有并发的含义,但并发不一定并行,也亦是说并发事件之间不一定要同一时刻发生。

同步和异步的区别

  • 同步(synchronous):进程之间的关系不是相互排斥临界资源的关系,而是相互依赖的关系。进一步的说明:就是前一个进程的输出作为后一个进程的输入,当第一个进程没有输出时第二个进程必须等待。具有同步关系的一组并发进程相互发送的信息称为消息或事件。
  • 异步(asynchronous):异步和同步是相对的,同步就是顺序执行,执行完一个再执行下一个,需要等待、协调运行。异步就是彼此独立,在等待某事件的过程中继续做自己的事,不需要等待这一事件完成后再工作。线程就是实现异步的一个方式。异步是让调用方法的主线程不需要同步等待另一线程的完成,从而可以让主线程干其它的事情。

线程

什么是线程

  • light weight process 轻量级的进程,本质仍是进程(在Linux环境下)
  • 拥有PCB(在Linux下与进程一样拥有PCB)
  • 没有独立的地址空间(共享)
  • 最小的执行单位,调度的基本单位。
  • 一切进程至少都有一个线程

线程和进程的区别

有关进程相关的内容可以查看我的另外一篇文章:{% btn https://blog.xinmouren.cn/posts/c3d75a3e.html, Linux之进程%}

线程和进程的区别如下:

  1. 进程是资源竞争的基本单位
  2. linux下没有真正意义的线程,因为linux下没有给线程设计专有的结构体,它的线程是用进程模拟的,而它是由多个进程共享一块地址空间而模拟得到的。
  3. 创建一个线程的资源成本小,工作效率高
  4. 进程是承担分配系统资源的基本实体,进程具有独立性(但进程间通信打破了独立性)
  5. 线程是cpu或操作系统调度的基本单位,线程具有共享性

线程共享的资源

  • 同一块地址空间
  • 文件描述符表
  • 每种信号的处理方式(如:SIG_DFL,SIG_IGN或者自定义的信号优先级)
  • 当前工作目录
  • 用户id和组id

线程独立的资源

  • 线程会产生临时变量,临时变量保存再栈上,所以每个线程都有自己的私有栈结构
  • 每个线程都有私有的上下文信息。
  • 线程ID
  • 一组寄存器的值
  • errno变量
  • 信号屏蔽字以及调度优先级

线程优、缺点

  • 优点: 1. 提高程序并发性 2. 开销小 3. 数据通信、共享数据方便
  • 缺点: 1. 库函数,不稳定 2. 调试、编写困难、gdb不支持 3. 对信号支持不好
  • 优点相对突出,缺点均不是硬伤。Linux下由于实现方法导致进程、线程差别不是很大

{% folding cyan , Linux内核线程实现原理 %}

类Unix系统中,早期是没有“线程”概念的,80年代才引入,借助进程机制实现出了线程的概念。因此在这类系统中,进程和线程关系密切。

  1. 轻量级进程(light-weight process),也有PCB,创建线程使用的底层函数和进程一样,都是clone。
  2. 从内核里看进程和线程是一样的,都有各自不同的PCB,但是PCB中指向内存资源的三级页表是相同的。
  3. 进程可以蜕变成线程
  4. 线程可看做寄存器和栈的集合
  5. 在linux下,线程最是小的执行单位;进程是最小的分配资源单位
  6. 对于进程来说,相同的地址(同一个虚拟地址)在不同的进程中,反复使用而不冲突。原因是他们虽虚拟址一样,但,页目录、页表、物理页面各不相同。相同的虚拟址,映射到不同的物理页面内存单元,最终访问不同的物理页面。
  7. 两个线程具有各自独立的PCB,但共享同一个页目录,也就共享同一个页表和物理页面。所以两个PCB共享一个地址空间。
  8. 无论是创建进程的fork,还是创建线程的pthread_create,底层实现都是调用同一个内核函数clone。
  9. 如果复制对方的地址空间,那么就产出一个“进程”;如果共享对方的地址空间,就产生一个“线程”。
  10. Linux内核是不区分进程和线程的。只在用户层面上进行区分。所以,线程所有操作函数pthread_*库函数,而非系统调用。

{% endfolding %}

线程控制

POSIX线程库

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
  • 要使用这些函数库,要通过引入头文件
  • 链接这些线程函数库时要使用编译器命令的-lpthread选项
  1. #include <pthread.h>

创建线程

其作用,对应进程中fork() 函数

函数原型:

  1. int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

参数:

  • pthread_t:在Linux中可理解为:typedef unsigned long int pthread_t;
  • 参数1:传出参数,保存系统为我们分配好的线程ID
  • 参数2:通常传NULL,表示使用线程默认属性。若想使用具体属性也可以修改该参数。
  • 参数3:函数指针,指向线程主函数(线程体),该函数运行结束,则线程结束。
  • 参数4:线程主函数执行期间所使用的参数,如要传多个参数, 可以用结构封装。

返回值:

  • 成功:0
  • 失败:错误号 ——-Linux环境下,所有线程特点,失败均直接返回错误号。

获取线程ID

其作用对应进程中 getpid() 函数

函数原型:

  1. pthread_t pthread_self(void);

返回值:

  • 返回线程ID,pthread_t类型,在Linux下为无符号整数(%lu),其他系统中可能是结构体实现

{% noteblock::注意 %}

  • 线程ID是进程内部识别标志。(两个进程间,线程ID允许相同)
  • 不应使用全局变量 pthread_t tid,在子线程中通过pthread_create传出参数来获取线程ID,而应使用pthread_self

{% endnoteblock %}

来看下面的例子:

  1. #include <stdio.h>
  2. #include <pthread.h>
  3. #include <unistd.h>
  4. void *fun(void *arg)
  5. {
  6. printf("I'm thread, Thread ID = %lu\n", pthread_self());
  7. return NULL;
  8. }
  9. int main(void)
  10. {
  11. pthread_t tid;
  12. pthread_create(&tid, NULL, fun, NULL);
  13. sleep(1);
  14. printf("I am main, my pid = %d\n", getpid());
  15. return 0;
  16. }

这里需要注意的有:

  1. 线程函数的返回值必须是空指针类型,如果不是则会有一个警告
  2. 父线程退出时,子线程均会强制退出
  3. 使用gcc编译的时候要加上-lpthread参数

上述代码的运行结果如下:

linux线程 - 图1

  • 在一个线程中调用pthread_create()创建新的线程后,当前线程从pthread_create()返回继续往下执行,而新的线程所执行的代码由我们传给pthread_create的函数指针start_routine决定。start_routine函数接收一个参数,是通过pthread_createarg参数传递给它的,该参数的类型为void *,这个指针按什么类型解释由调用者自己定义。start_routine的返回值类型也是void *,这个指针的含义同样由调用者自己定义。start_routine返回时,这个线程就退出了,其它线程可以调用pthread_join得到start_routine的返回值,类似于父进程调用wait()得到子进程的退出状态
  • pthread_create成功返回后,新创建的线程的id被填写到thread参数所指向的内存单元。我们知道进程id的类型是pid_t,每个进程的id在整个系统中是唯一的,调用getpid()可以获得当前进程的id,是一个正整数值。线程id的类型是thread_t,它只在当前进程中保证是唯一的,在不同的系统中thread_t这个类型有不同的实现,它可能是一个整数值,也可能是一个结构体,也可能是一个地址,所以不能简单地当成整数用printf打印,调用pthread_self()可以获得当前线程的id。

线程退出

函数原型:

  1. void pthread_exit(void *retval);

参数:

  • retval:表示线程退出状态,通常传NULL

请看下面的例子:

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

void *fn(void *arg)
{
    long i = 0;
    //强制类型转化
    i = (long)arg;
    //通过i来区别每个线程
    if (i == 2)
        pthread_exit(NULL);
    sleep(i);
    printf("I'm %ldth thread, Thread_ID = %lu\n", i + 1, pthread_self());
    return NULL;
}

int main(int argc, char *argv[])
{
    long n, i;
    pthread_t tid;
    //接受参数
    if (argc == 2)
        n = atoi(argv[1]);
    else
        exit(0);
    //循环创建线程
    for (i = 0; i < n; i++)
    {
        pthread_create(&tid, NULL, fn, (void *)i);
    }

    sleep(n);
    printf("I am main, I'm a thread!\n");
    printf("main_thread_ID = %lu\n", pthread_self());

    return 0;
}

上面的代码运行结果如下:

linux线程 - 图2

上面的代码有几点需要说明:

  1. 我们在创建线程的时候,传入了参数,在传参的时候,首先把我们要传的参数强制转化为void *类型,然后再在线程函数中对传入参数进行强制类型转化。
  2. 上面我们定义i的时候,定义为long类型(n随便),这是因为void *类型的长度是8,int类型是4,所以如果i被定义为int类型,那么会在编译的时候看到一个警告,如下:
    linux线程 - 图3

{% noteblock::注意 %}

  • 多线程编程中不能使用exit来退出线程,因为exit时进程操作函数,调用该函数的时候会导致进程退出,也就是导致所有的线程都退出。
  • 多线程环境中,应尽量少用,或者不使用exit函数,取而代之使用pthread_exit函数,将单个线程退出。
  • 主线程一般情况下也不要使用exit或者return来退出,因为一旦主线程以这两种方式退出,其他所有线程都会退出。
  • pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

{% endnoteblock %}

等待线程退出

其作用,对应进程中 waitpid() 函数。

函数原型:

int pthread_join(pthread_t thread, void **retval);

参数:

  • thread:线程ID
  • retval:存储线程结束状态,即线程的返回值

看下面的例子:

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

//自定义结构体
typedef struct
{
    int a;
    int b;
} exit_t;

void *tfn(void *arg)
{
    exit_t *ret;
    //申请空间
    ret = malloc(sizeof(exit_t)); 
    //赋值
    ret->a = 100;
    ret->b = 300;

    sleep(3);
    return (void *)ret;
}

int main(void)
{
    pthread_t tid;
    exit_t *retval;

    pthread_create(&tid, NULL, tfn, NULL);
    //阻塞等待线程线程结束
    pthread_join(tid, (void **)&retval); 
    //打印返回值
    printf("a = %d, b = %d \n", retval->a, retval->b);
    return 0;
}

上面的代码运行后结果如下:

linux线程 - 图4

实际上在运行之后,没有任何的输出,等待3秒之后,才打印出a,b的值,原因在于pthread_join是阻塞式的,在子线程退出之前,代码是不会往下进行的。

{% folding 如果在栈空间上分配会怎么呢? %}

我们在栈上分配空间,然后获取线程的返回值:

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

//自定义结构体
typedef struct
{
    int a;
    int b;
} exit_t;

void *tfn(void *arg)
{
    exit_t ret;
    //赋值
    ret.a = 100;
    ret.b = 300;

    sleep(3);
    exit_t *p = &ret;
    return (void *)p;
}

int main(void)
{
    pthread_t tid;
    exit_t *retval;

    pthread_create(&tid, NULL, tfn, NULL);
    //阻塞等待线程线程结束
    pthread_join(tid, (void **)&retval);
    //打印返回值
    printf("a = %d, b = %d \n", retval->a, retval->b);
    return 0;
}

可以看到,返回值变得不确定了:

linux线程 - 图5

其实很好理解,就和函数退出一样,线程退出后,栈空间会被释放。

{% endfolding %}

{% noteblock::注意 %}

有关retval返回值:

  1. 如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。
  2. 如果thread线程被别的线程调用pthread_cancel异常终止掉,retval所指向的单元里存放的是常数PTHREAD_CANCELED
  3. 如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数。
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给retval参数。

{% endnoteblock %}

杀死线程

其作用,对应进程中 kill() 函数。

函数原型:

int pthread_cancel(pthread_t thread);

返回值:

  • 成功:0
  • 失败:错误号

看下面的例子:

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

void *tfn1(void *arg)
{
    printf("thread 1 returning\n");
    return (void *)1;
}

void *tfn2(void *arg)
{
    printf("thread 2 exiting\n");
    pthread_exit((void *)2);
}

void *tfn3(void *arg)
{
    while (1)
    {
        printf("thread 3: I'm going to die in 3 seconds ...\n");
        sleep(1);
        pthread_testcancel(); //自己添加取消点
    }

    return (void *)3;
}

int main(void)
{
    pthread_t tid;
    void *tret = NULL;

    pthread_create(&tid, NULL, tfn1, NULL);
    pthread_join(tid, &tret);
    printf("thread 1 exit code = %ld\n", (long)tret);

    pthread_create(&tid, NULL, tfn2, NULL);
    pthread_join(tid, &tret);
    printf("thread 2 exit code = %ld\n", (long)tret);

    pthread_create(&tid, NULL, tfn3, NULL);
    sleep(3);
    pthread_cancel(tid);
    pthread_join(tid, &tret);
    printf("thread 3 exit code = %ld\n", (long)tret);

    return 0;
}

上述代码运行结果如下:

linux线程 - 图6

在上述代码中:

  1. 线程1、2使用return返回,我们在主线程中成功使用pthread_join捕获到了返回值
  2. 线程的取消并不是实时的,而有一定的延时。需要等待线程到达某个取消点(检查点)。也就是当发出杀死线程的信号后,线程不会被立即杀死,而是等待某种时机成熟时才会被杀死。
  3. 取消点:是线程检查是否被取消,并按请求进行动作的一个位置。通常是一些系统调用creat,open,pause,close,read,write…..
  4. 可粗略认为一个系统调用(进入内核)即为一个取消点。如线程中没有取消点,可以通过调用pthread_setcancelstate函数自行设置一个取消点。
  5. 被取消的线程, 退出值定义在Linux的pthread库中。常数PTHREAD_CANCELED的值是-1。可在头文件pthread.h中找到它的定义:#define PTHREAD_CANCELED ((void *) -1)。因此当我们对一个已经被取消的线程使用pthread_join回收时,得到的返回值为-1。

{% noteblock::注意 %}

也就是说:pthread_cancel只是发出了杀死线程的信号,而真正执行杀死操作的是在进入取消点的时候

{% endnoteblock %}

设置取消点

pthread_setcancelstate

函数原型:

int pthread_setcancelstate(int state,int *oldstate)

参数:

  • state:
    • PTHREAD_CANCEL_ENABLE (缺省):表示收到信号后设为CANCLED状态
    • PTHREAD_CANCEL_DISABLE:忽略CANCEL信号继续运行
  • old_state
    • 不为NULL则存入原来的Cancel状态以便恢复。
    • 为空,则不存储原cencel状态

举例:

int iOldState;
pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &iOldState);
dosomething();
pthread_setcancelstate(iOldState, NULL);

上述代码表示:

在执行dosomething()的时候忽略收到的 CANCEL 信号继续执行,结束后再设置为原始状态。

pthread_setcanceltype

函数原型:

int pthread_setcanceltype(int type, int *oldtype)

参数:

设置本线程取消动作的执行时机,type由两种取值:

  • PTHREAD_CANCEL_DEFFERED
  • PTHREAD_CANCEL_ASYCHRONOUS

仅当Cancel状态为Enable时有效(pthread_setcancelstate(PTHREAD_CANCEL_ENABLE , NULL);),分别表示收到信号后继续运行至下一个取消点再退出和立即执行取消(退出)动作;oldtype如果不为NULL则存入用来取消的动作类型值。

pthread_testcancel

pthread_testcancel用在一些不包含取消点,但是又需要取消点的地方创建一个取消点,以便在一个没有包含取消点的执行代码线程中响应取消请求

函数原型:

void pthread_testcancel(void)

线程分离

函数原型:

int pthread_detach(pthread_t thread);
  • 线程分离状态:指定该状态,线程主动与主控线程断开关系。线程结束后,其退出状态不由其他线程获取,而直接自己自动释放。网络、多线程服务器常用。
  • 在线程被分离后,不能使用pthread_join等待它的终止状态。
  • 进程如果有该机制,将不会产生僵尸进程。僵尸进程的产生主要由于进程死后,大部分资源被释放,一点残留资源仍存于系统中,导致内核认为该进程仍存在。
  • 也可使用pthread_create第二个参数来设置线程分离。

代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>

void *tfn(void *arg)
{
    int n = 3;
    while (n--)
    {
        printf("thread count %d\n", n);
        sleep(1);
    }
    return (void *)1;
}

int main(void)
{
    pthread_t tid;
    void *tret;
    long err;

    /*通过线程属性来设置游离态*/
    pthread_attr_t attr; 
    pthread_attr_init(&attr);
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    pthread_create(&tid, &attr, tfn, NULL);

    //两种方法都可以
    // pthread_create(&tid, NULL, tfn, NULL);
    // pthread_detach(tid);
    while (1)
    {
        err = pthread_join(tid, &tret);
        printf("thread exit code = %ld\n", (long)tret);
        printf("err= %ld\n", err);
        if (err != 0)
            fprintf(stderr, "thread_join error: %s\n\n", strerror(err));
        else
            fprintf(stderr, "thread exit code %ld\n\n", (long)tret);

        sleep(1);
    }
    return 0;
}

上述代码运行结果如下:

linux线程 - 图7

  • 一般情况下,线程终止后,其终止状态一直保留到其它线程调用pthread_join获取它的状态为止。但是线程也可以被置为detach状态,这样的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态。不能对一个已经处于detach状态的线程调用pthread_join,这样的调用将返回EINVAL错误。也就是说,如果已经对一个线程调用了pthread_detach就不能再调用pthread_join了。