写点什么

Linux 多线程 - 概念及控制

作者:可口也可樂
  • 2022 年 5 月 05 日
  • 本文字数:7472 字

    阅读完需:约 25 分钟

Linux多线程-概念及控制

@TOC

零、前言

本章主要讲解学习 Linux 中的线程

一、Linux 线程概念

1、什么是线程

  • 概念:


  1. 在一个程序里的一个执行路线就叫做线程(thread),更准确的定义是:线程是“一个进程内部的控制序列”

  2. 一切进程至少都有一个执行线程,也就是主线程,进程由一个或者多个线程组成,即进程中可以有多个执行流

  3. 线程是进程的一个执行分支,实在进程内部运行的一个执行流,本质是在进程地址空间内运行,共享进程的进程地址空间,执行进程的一部分代码


  • 以整个运行视角理解:


  1. 程序运行,将代码和数据加载到 CPU 上,同时系统创建对应的进程进行承担分配系统资源,如创建 task_struct 结构体,构建对应的进程地址空间,页表建立虚拟地址与物理地址的映射等等,即进程是承担分配系统资源的基本单元

  2. 在进程中可能存在多个执行流(一定有个主执行流),也就是线程,而这些执行流都是由 task_struct 描述的,共享同一个进行地址空间,透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流,执行程序的部分代码,这些执行流可以进行并发执行,由于是在进行内部运行,不用切换整个进程的上下文数据,只需切换线程的上下文数据,即线程是系统调度的基本单元


  • 示图:



注:在 Linux 系统下的 CPU 眼中,看到的 PCB(task_struct)都要比传统的进程更加轻量化


  • 如何理解之前所说的'进程':


进程是一个大的整体,包括 task_struct(PCB),进程地址空间、文件、信号等,是承担分配系统资源的基本实体,而之前所受的进程都只有一个 task_struct,也就是该进程内部只有一个执行流


  • 注意:


  1. 在 Linux 中,CPU 只关心一个一个的独立执行流,无论进程内部只有一个执行流还是有多个执行流,CPU 都是以 task_struct 为单位进行调度的

  2. Linux 下并不存在真正的多线程,而是用进程模拟的。如果要支持真的线程(TCB)会提高操作系统的复杂程度。而线程的和进程的控制块基本是类似实现的,因此 Linux 直接复用了进程控制块,所以 Linux 中的所有执行流都叫做轻量级进程

  3. 在 Linux 中都没有真正意义的线程,所以也就没有真正意义上的线程相关的系统调用,但是 Linux 提供了轻量级进程相关的库和接口,例如 vfork 函数和原生线程库 pthread

2、vfork 函数/pthread 线程库

  • vfork 函数原型:


pid_t vfork(void);
复制代码


  • 注意:


  1. 功能:创建子进程,但是父子共享进程地址空间

  2. 返回值:成功给父进程返回子进程的 PID;给子进程返回 0


示例:


#include<stdio.h>#include<unistd.h>#include<sys/types.h>
int main(){ int val=100; pid_t id=vfork(); if(id==0) { //child int cnt=0; while(1) { printf("i am child pid:%d ppid:%d val:%d &val:%p\n",getpid(),getppid(),val,&val); cnt++; sleep(1); if(cnt==2) val=200; if(cnt==5) exit(0); } } else if(id>0) { //father int cnt=0; while(1) { printf("i am father pid:%d ppid:%d val:%d &val:%p\n",getpid(),getppid(),val,&val); cnt++; sleep(1); if(cnt==3) val=300; } } return 0;}
复制代码


  • 效果:



注:vfork() 保证子进程先运行,在它调用 exec(进程替换) 或 exit(退出进程)之后父进程才可能被调度运行;如果子进程没有调用 exec, exit, 程序则会导致死锁,程序是有问题的程序,没有意义


  • 原生线程库 pthread:


  1. 在 Linux 中,站在内核角度没有真正意义上线程相关的接口,但是站在用户角度,当用户想创建一个线程时更期望使用 thread_create 这样类似的接口,因此系统为用户层提供了原生线程库 pthread

  2. 原生线程库实际就是对轻量级进程的系统调用进行了封装,在用户层模拟实现了一套线程相关的接口

3、线程优缺点及其他分析

  • 线程的优点:


  1. 创建一个新线程的代价要比创建一个新进程小得多

  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多线程占用的资源要比进程少很多

  3. 能充分利用多处理器的可并行数量

  4. 在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务

  5. 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

  6. I/O 密集型应用,为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作(如边下视频边看视频)


  • 注意:


  1. 计算密集型:执行流的大部分任务,主要以计算为主。比如加密解密、大数据查找等

  2. IO 密集型:执行流的大部分任务,主要以 IO 为主。比如刷磁盘、访问数据库、访问网络等


  • 线程的缺点:


  1. 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变

  2. 健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了;不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺保护的

  3. 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响

  4. 编程难度提高:编写与调试一个多线程程序比单线程程序困难得多


  • 线程异常:


  1. 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃

  2. 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出


  • 线程用途:


  1. 合理的使用多线程,能提高 CPU 密集型程序的执行效率

  2. 合理的使用多线程,能提高 IO 密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

二、Linux 进程 VS 线程

1、进程和线程

  • 概念:


  1. 进程是资源分配的基本单位

  2. 线程是调度的基本单位


  • 线程共享进程数据,但也有线程自己独有的数据:


  1. 线程 ID

  2. 一组寄存器中线程自己的上下文数据

  3. errno

  4. 信号屏蔽字(handler 方法是共享的)

  5. 调度优先级


  • 线程中共享的数据:


  1. 代码段和数据段

  2. 文件描述符表

  3. 每种信号的处理方式

  4. 当前工作目录

  5. 用户 id 和组 id


注:进程的多个线程共享同一地址空间,因此 Text Segment、Data Segment 都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到


  • 进程和线程的关系图:


三、Linux 线程控制

1、POSIX 线程库

  • pthread 线程库是应用层的原生线程库:


  1. 应用层指的是这个线程库并不是系统接口直接提供的,而是由第三方提供的

  2. 原生指的是大部分 Linux 系统都会默认带上该线程库

  3. 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的

  4. 要使用这些函数库,要通过引入头文件<pthreaad.h>

  5. 链接这些线程函数库时,要使用编译器命令的“-lpthread”选项


  • 错误检查:


  1. 传统的一些函数是,成功返回 0,失败返回-1,并且对全局变量 errno 赋值以指示错误

  2. pthreads 函数出错时不会设置全局变量 errno(而大部分 POSIX 函数会这样做),而是将错误代码通过返回值返回

  3. pthreads 同样也提供了线程内的 errno 变量,以支持其他使用 errno 的代码。对于 pthreads 函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的 errno 变量的开销更小

2、线程创建

  • pthread_create 函数原型:


int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
复制代码


  • 解释:


  1. 功能:创建一个新的线程

  2. 参数:thread:输出型参数,返回获取线程 ID;attr:设置线程的属性,attr 为 NULL 表示使用默认属性;start_routine:是个函数地址,线程启动后要执行的函数,该函数返回值为 void *,参数为 void *;arg:传给线程启动函数的参数

  3. 返回值:成功返回 0;失败返回错误码


  • 注意:


  1. 主线程调用 pthread_create 函数创建一个新线程,此后新线程就会跑去执行参入的函数,而主线程则继续往下执行

  2. 对于执行函数来说,参数和返回值的类型都是 void *,void *是一个通用的类型,可以传入或者返回数据和其他类型的指针,从而传入和带出多样的类型和数据


  • 示例:


mypthread.c:#include<stdio.h>#include<unistd.h>#include<pthread.h>#include<stdlib.h>#include<string.h>int val=0;void* Routine(void* avgs){    while(1)    {        printf("I am %s... val:%d\n",(char*)avgs,val);        sleep(1);    }}int main(){    pthread_t tid1,tid2,tid3;    int ret1=pthread_create(&tid1,NULL,Routine,(void*)"pthread 1");    if(ret1!=0)    {        fprintf(stderr,"pthread_creat:%s\n",strerror(ret1));        exit(1);    }    int ret2=pthread_create(&tid2,NULL,Routine,(void*)"pthread 2");    if(ret2!=0)    {        fprintf(stderr,"pthread_creat:%s\n",strerror(ret2));        exit(1);    }    int ret3=pthread_create(&tid3,NULL,Routine,(void*)"pthread 3");    if(ret3!=0)    {        fprintf(stderr,"pthread_creat:%s\n",strerror(ret3));        exit(1);    }    while(1)    {        printf("I am main pthread...val:%d\n",val++);        sleep(1);    }    return 0;}Makefile:mypthread:mypthread.c    gcc -o  $@ $^ -pthread .PHONY:cleanclean:    rm -f mypthread
复制代码


  • 效果:



  • 查看线程信息:ps -aL


  • 注意:


  1. 默认情况下,ps 命令不带-L,看到的就是一个个的进程;带-L就可以查看到每个进程内的多个轻量级进程

  2. 在 Linux 中,应用层的线程与内核的 LWP 是一一对应的,实际上操作系统调度的时候采用的是 LWP,而并非 PID,只不过我们之前接触到的都是单线程进程,其 PID 和 LWP 是相等的

3、线程 ID 及线程地址空间布局

  • 概念:


  1. pthread_ create 函数会产生一个线程 ID,存放在第一个参数指向的地址中。该线程 ID 和前面说的线程 ID 不是一回事

  2. 前面讲的线程 ID(LWP)属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程

  3. pthread_ create 函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程 ID,属于 NPTL 线程库的范畴。线程库的后续操作,就是根据该线程 ID 来操作线程的

  4. 在 Linux 系统层面有 LWP 与线程对应,但是 Linux 是用轻量级进程模拟的线程,而对于用户来说,并不会关心底层实现,从用户角度来说,他们也需要知道线程的信息,状态以及操作线程,由此在共享区中还相应的构建了 TCB(线程控制块),便于用户操作线程,在用户区进行维护


  • pthread_ self 函数原型:


pthread_t pthread_self(void);
复制代码


功能:获得线程自身的 ID

注:对于 Linux 目前实现的 NPTL 实现而言,pthread_t 类型的线程 ID,本质就是一个进程地址空间上的一个地址


  • 示图:



注:主线程并不使用动态库里的线程栈,而是使用进程里的栈

4、线程终止

  • 终止线程的三种方法:


  1. 从线程函数 return

  2. 线程可以调用 pthread_ exit 终止自己

  3. 线程可以调用 pthread_ cancel 终止同一进程中的另一个线程或者自己

注:在主线程使用 return,以及在线程中使用 exit 都会终止整个进程


  • pthread_exit 函数原型:


void pthread_exit(void *value_ptr);
复制代码


  • 解释:


  1. 功能:线程终止

  2. 参数:value_ptr 线程退出传出的数据(不要指向一个局部变量)

  3. 返回值:无返回值,跟进程一样,线程结束的时候无法返回到它自身

注:pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了


  • pthread_cancel 函数原型:


int pthread_cancel(pthread_t thread);
复制代码


  • 解释:


  1. 功能:取消一个执行中的线程

  2. 参数:thread 表示要操作的线程的 ID

  3. 返回值:成功返回 0;失败返回错误码

注:pthread_cancel 函数具有一定的延时性,并不会立即被处理,不建议当线程立即被创建后立即进行 cancel 取消(线程创建,并不会立即被调度);也不建议在线程退出前执行线程 cancel 取消(线程可能在取消之前就已经退出了);建议在线程执行中进行 cancel 取消线程


示例:


#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <pthread.h>void *thread1(void *arg){    printf("%s returning ... \n",(char*)arg);    int *p = (int*)malloc(sizeof(int));    *p = 1;    return (void*)p;}void *thread2(void *arg){    printf("%s exiting ...\n",(char*)arg);    int *p = (int*)malloc(sizeof(int));    *p = 2;    pthread_exit((void*)p);} void *thread3(void *arg){    while ( 1 ){ //        printf("%s is running ...\n",(char*)arg);        sleep(1);    }     return NULL;} int main( void ){    pthread_t tid;    void *ret;    // thread 1 return    pthread_create(&tid, NULL, thread1, (void*)"thread 1");    pthread_join(tid, &ret);    printf("thread 1 return, thread id %X, return code:%d\n", tid, *(int*)ret);    free(ret);    // thread 2 exit    pthread_create(&tid, NULL, thread2, (void*)"thread 2");    pthread_join(tid, &ret);    printf("thread 2 exit, thread id %X, return code:%d\n", tid, *(int*)ret);    free(ret);    // thread 3 cancel by other    pthread_create(&tid, NULL, thread3, (void*)"thread 3");    sleep(3);    pthread_cancel(tid);    pthread_join(tid, &ret);    if (ret == PTHREAD_CANCELED)        printf("thread 3 cancel, thread id %X, return code: PTHREAD_CANCELED->%d\n", tid,ret);    else        printf("thread return, thread id %X, return code:%d\n", tid,ret);    return 0;}
复制代码


  • 效果:


5、线程等待

  • 为什么需要线程等待:


  1. 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内,创建新的线程不会复用刚才退出线程的地址空间,如果主线程不对新线程进行等待,那么这个新线程的资源也是不会被回收的。如果不等待会产生内存泄漏

  2. 线程是用来执行分配的任务的,如果主线程想知道任务完成的怎么样,那么就有必要对线程进行等待,获取线程退出的信息


  • pthread_join 函数原型:


int pthread_join(pthread_t thread, void **value_ptr);
复制代码


  • 解释:


  1. 功能:等待线程结束

  2. 参数:thread:指定等待线程的 ID;value_ptr:输出型参数,用来获取指向线程的返回值

  3. 返回值:成功返回 0;失败返回错误码


  • 注意:


  1. 调用该函数的线程将挂起等待,直到 id 为 thread 的线程终止

  2. 这里获取的线程退出信息并没有终止信号信息,而终止信号信息是对于整个进程来说的,如果线程收到信号崩溃也会导致整个进程也崩溃

  3. thread 线程以不同的方法终止,通过 pthread_join 得到的终止状态是不同的


  • 终止获取的状态情况:


  1. 如果 thread 线程通过 return 返回,value_ ptr 所指向的单元里存放的是 thread 线程函数的返回值

  2. 如果 thread 线程被别的线程调用 pthread_ cancel 异常终掉,value_ ptr 所指向的单元里存放的是常数 PTHREAD_ CANCELED

  3. 如果 thread 线程是自己调用 pthread_exit 终止的,value_ptr 所指向的单元存放的是传给 pthread_exit 的参数

  4. 如果对 thread 线程的终止状态不感兴趣,可以传 NULL 给 value_ ptr 参数


  • 示图:



  • 示例:


#include<stdio.h>#include<unistd.h>#include<pthread.h>#include<stdlib.h>#include<string.h>int val=0;struct Ret {    int exitno;    int exittime;    //...};void* Routine(void* avgs){    int cnt=1;    while(1)    {        printf("I am %s... val:%d\n",(char*)avgs,val);        sleep(1);        cnt++;        if(cnt==3)        {            struct Ret* p=(struct Ret*)malloc(sizeof(struct Ret));            p->exitno=0;            p->exittime=6666;            pthread_exit((void*)p);            //pthread_cancel(pthread_self());        }        }
}int main(){ pthread_t tid1,tid2,tid3; pthread_create(&tid1,NULL,Routine,(void*)"pthread 1"); pthread_create(&tid2,NULL,Routine,(void*)"pthread 2"); pthread_create(&tid3,NULL,Routine,(void*)"pthread 3"); int cnt=0; while(1) { printf("I am main pthread...val:%d\n",val++); sleep(1); cnt++; if(cnt==3) break; } printf("wait for pthread...\n"); void* ret; pthread_join(tid1,&ret); printf("pthread id:%x exitno:%d exittime:%d\n",tid1,((struct Ret*)ret)->exitno,((struct Ret*)ret)->exittime); pthread_join(tid2,&ret); printf("pthread id:%x exitno:%d exittime:%d\n",tid2,((struct Ret*)ret)->exitno,((struct Ret*)ret)->exittime); pthread_join(tid3,&ret); printf("pthread id:%x exitno:%d exittime:%d\n",tid3,((struct Ret*)ret)->exitno,((struct Ret*)ret)->exittime); return 0;}
复制代码


  • 效果:



6、线程分离

  • 概念:


  1. 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏

  2. 如果不关心线程的返回值,join 是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源


  • pthread_detach 函数原型:


int pthread_detach(pthread_t thread);
复制代码


  • 注意:


  1. 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离: pthread_detach(pthread_self());

  2. joinable 和分离是冲突的,一个线程不能既是 joinable 又是分离的

  3. 线程的分离也是具有一定延时性,分离之后如果再进行等待那么得到返回的结果是未定义的

  4. 线程分离后只是回收的时候自动进行回收,如果主线程先退出,那么整个进程也会退出;如果分离的线程执行崩溃,同样的整个进行也会崩溃


  • 示例:


#include <stdio.h>#include <pthread.h>#include <unistd.h>void* Routine (void* arg){    pthread_detach(pthread_self());    printf("%s detach success!\n");    int cnt=0;    while(cnt<5)    {        cnt++;        printf("%s running...\n",(char*)arg);        sleep(1);    }    printf("%s return...\n");    return NULL;}int main(){    pthread_t tid;    pthread_create(&tid,NULL,Routine,(void*)"thread");        sleep(2);//等待线程分离
void* ret; if(pthread_join(tid,&ret)==0) printf("thread join success! ret:%d\n",(int*)ret); else printf("thread join fail... ret:%d\n",(int*)ret); return 0;}
复制代码


  • 效果:


发布于: 2 小时前阅读数: 5
用户头像

还未添加个人签名 2022.04.28 加入

还未添加个人简介

评论

发布
暂无评论
Linux多线程-概念及控制_c++_可口也可樂_InfoQ写作社区