Linux(十) 线程,线程控制

目录

一、认识线程

1.1 线程是什么

1.2 为啥要有线程

并行与并发

为什么要有线程(线程的优点)

为什么线程的切换成本更低

1.3 线程的缺点

1.4 线程和进程的区别

二、线程控制

2.1 线程创建

进程ID和线程ID

2.2 线程终止

2.3 线程等待

2.4 线程分离

三、注意


一、认识线程

1.1 线程是什么

  1. 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”
  2. 一切进程至少都有一个执行线程
  3. 线程在进程内部运行,本质是在进程地址空间内运行
  4. 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
  5. 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流

1.2 为啥要有线程

并行与并发

首先先介绍一下并行与并发

  • 并发:指两个或多个事件在同⼀个时间段内发⽣。
  • 并⾏:指两个或多个事件在同⼀时刻发⽣(同时发⽣)。

在操作系统中,安装了多个程序,并发指的是在⼀段时间内宏观上有多个程序同时运⾏,这在单 CPU 系统中,每⼀时刻只能有⼀道程序执⾏,即微观上这些程序是分时的交替运⾏,只不过是给⼈的感觉是同时运 ⾏,那是因为分时交替运⾏的时间是⾮常短的。
⽽在多个 CPU 系统中,则这些可以并发执⾏的程序便可以分配到多个处理器上( CPU ),实现多任务并⾏ 执⾏,即利⽤每个处理器来处理⼀个可以并发执⾏的程序,这样多个程序便可以同时执⾏。⽬前电脑市场 上说的多核 CPU ,便是多核处理器,核 越多,并⾏处理的程序越多,能⼤⼤的提⾼电脑运⾏的效率。

注意:单核处理器的计算机肯定是不能并⾏的处理多个任务的,只能是多个任务在单个 CPU 上并发运 ⾏。同理 , 线程也是⼀样的,从宏观⻆度上理解线程是并⾏运⾏的,但是从微观⻆度上分析却是串⾏ 运⾏的,即⼀个线程⼀个线程的去运⾏,当系统只有⼀个 CPU 时,线程会以某种顺序执⾏多个线程, 我们把这种情况称之为线程调度。

为什么要有线程(线程的优点)

“并发编程” 成为 “刚需”.

  • 单核 CPU 的发展遇到了瓶颈. 要想提高算力, 就需要多核 CPU. 而并发编程能更充分利用多核 CPU 资源.

  • 有些任务场景需要 “等待 IO”, 为了让等待 IO 的时间能够去做⼀些其他的工作, 也需要用到并发编程. 

  • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现,提高效率
    I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

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

  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多

  • 线程占用的资源要比进程少很多

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

最后, 线程虽然比进程轻量, 但是人们还不满足, 于是又有了 “线程池”(ThreadPool) 和 “协程”(Coroutine)关于线程池我们后面再介绍. 关于协程的话题我们此处暂时不做过多讨论.

为什么线程的切换成本更低

1.地址空间&&页表不需要切换,进程PCB,页表,进程地址空间的地址会保存在CPU寄存器中,切换进程要切换它们,但是其实成本并不高。
2.这条原因成本比较高,是主要原因。一条代码附近的代码有较高概率被使用,所以根据这条局部性原理,会将内存中的代码和数据预读进CPU内部,CPU内部有L1~L3 cache(缓存),对预读的内容进行保存。如果进程切换,cache就立即失效,新进程过来只能重新缓存。

1.3 线程的缺点

  • 性能损失
  • 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  • 健壮性降低
  • 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
  • 缺乏访问控制
  • 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  • 编程难度提高
  • 编写与调试一个多线程程序比单线程程序困难得多

1.4 线程和进程的区别

进程是资源分配的基本单位,在用户视角里,进程 = 内核的PCB结构 + 该进程对应的代码和数据
在内核视角,承担系统资源分配的基本实体。

线程在进程内部进行,是CPU调度的基本单位。线程在进程的地址空间内运行,CPU其实不关心执行流是进程还是线程,只关心PCB(task_struct)。

进程向操作系统要资源,线程是进程给他资源。

在Linux下,PCB <= 其他OS的PCB,Linux下的进程统一称为轻量级进程,Linux没有真正意义上的线程结构,使用进程PCB模拟的线程结构,即Linux并不能给我提供线程的相关接口,只能提供轻量级进程的接口,所以为了方便使用,在用户层实现了一套用户层多进程方案,以库的方式提供给用户使用,pthread线程库--原生线程库。

线程共享进程数据,但也拥有自己的一部分数据,私有:

  • 线程ID
  • 一组寄存器
  • errno
  • 信号屏蔽字
  • 调度优先级

一组寄存器和栈是私有的体现了线程的动态属性,线程是被调度的,他就得有上下文,线程也是要被执行的,它就必须有栈来保存临时数据和出栈入栈信息

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

  • 文件描述符表
  • 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id
  • 代码区
  • 全局变量区
  • 堆区(可以被共享的,但一般认为是私有的)

如何看待之前学习的单进程?具有一个线程执行流的进程
 

二、线程控制

POSIX线程库

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

2.1 线程创建

功能:创建一个新的线程
原型
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)
(void*), void *arg);
参数
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码

错误检查:

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
  • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回
  • pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <string>
#include <pthread.h>
using namespace std;
int x = 100;
void show(const string& name)
{
    cout << name << "pid:" << getpid() << " " << x << '\n' << endl;
}

void *threadrun(void *args)
{
    const string name = (char *)args;
    while (true)
    {
        show(name);
    
        sleep(1);
    }
}

int main()
{
    pthread_t tid[5];
    char name[64];
    for (int i = 0; i < 5; i++)
    {
        snprintf(name, sizeof name, "%s-%d", "thread", i);
        pthread_create(tid + i, nullptr, threadrun, (void *)name);
        sleep(1); // 缓解传参的bug
    }
    while (true)
    {
        cout << "main thread,pid:" << getpid() << endl;
        sleep(3);
    }

    return 0;
}

进程ID和线程ID

  • 在Linux中,目前的线程实现是Native POSIX Thread Libaray,简称NPTL。在这种实现下,线程又被称为轻量级进程(Light Weighted Process),每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描述符(task_struct结构体)。
  • 没有线程之前,一个进程对应内核里的一个进程描述符,对应一个进程ID。但是引入线程概念之后,情况发生了变化,一个用户进程下管辖N个用户态线程,每个线程作为一个独立的调度实体在内核态都有自己的
  • 进程描述符,进程和内核的描述符一下子就变成了1:N关系,POSIX标准又要求进程内的所有线程调用getpid函数时返回相同的进程ID,如何解决上述问题呢?
  • Linux内核引入了线程组的概念。
  • struct task_struct {
    ...
    pid_t pid;
    pid_t tgid;
    ...
    struct task_struct *group_leader;
    ...
    struct list_head thread_group;
    ...
    };
  • 多线程的进程,又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符(task_struct)与之对应。进程描述符结构体中的pid,表面上看对应的是进程ID,其实不然,它对应的是线程ID;进程描述符中的tgid,含义是Thread Group ID,该值对应的是用户层面的进程ID。

现在介绍的线程ID,不同于pthread_t类型的线程ID,和进程ID一样,线程ID是pid_t类型的变量,而且是用来唯一标识线程的一个整型变量。如何查看一个线程的ID呢?
 

[dgz@VM-8-2-centos lesson35]$ while true ; do ps -eLF | head -1 && ps -eLF | grep mythread | grep -v grep; sleep 1;done 
UID        PID  PPID   LWP  C NLWP    SZ   RSS PSR STIME TTY          TIME CMD
dgz       1905 31171  1905  0    6 96014  1340   0 22:23 pts/0    00:00:00 ./mythread
dgz       1905 31171  1906  0    6 96014  1340   0 22:23 pts/0    00:00:00 ./mythread
dgz       1905 31171  1912  0    6 96014  1340   1 22:23 pts/0    00:00:00 ./mythread
dgz       1905 31171  1916  0    6 96014  1340   1 22:23 pts/0    00:00:00 ./mythread
dgz       1905 31171  1921  0    6 96014  1340   0 22:23 pts/0    00:00:00 ./mythread
dgz       1905 31171  1934  0    6 96014  1340   0 22:23 pts/0    00:00:00 ./mythread

ps命令中的-L选项,会显示如下信息

  • LWP:线程ID,既gettid()系统调用的返回值。
  • NLWP:线程组内线程的个数
  • Linux提供了gettid系统调用来返回其线程ID,可是glibc并没有将该系统调用封装起来,在开放接口来共程序员使用。如果确实需要获得线程ID,可以采用如下方法: #include <sys/syscall.h> pid_t tid; tid = syscall(SYS_gettid);
  • 从上面可以看出,mythread进程的ID为31171,下面有一个线程的ID也是31171,这不是巧合。线程组内的第一个线程,在用户态被称为主线程(main thread),在内核中被称为group leader,内核在创建第一个线程时,会将线程组的ID的值设置成第一个线程的线程ID,group_leader指针则指向自身,既主线程的进程描述符。所以线程组内存在一个线程ID等于进程ID,而该线程即为线程组的主线程。
  • 至于线程组其他线程的ID则有内核负责分配,其线程组ID总是和主线程的线程组ID一致,无论是主线程直接创建线程,还是创建出来的线程再次创建线程,都是这样。
  • 强调一点,线程和进程不一样,进程有父进程的概念,但在线程组里面,所有的线程都是对等关系

同一个线程组的线程没有层次关系

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
  • 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:
     pthread_t pthread_self(void);

pthread_t到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。

pthread_t tid;
typedef unsigned long int pthread_t;

怎么保证栈区是一个线程独占,不相互影响呢?
首先我们要知道OS是不知道线程存在的,所以是用户区保证的,先说结论,线程用的栈结构是共享区的栈结构,他是在pthread动态库里,因为库文件是映射到内存地址空间共享区中的,库的地址是线性的,所以为了方便线程找到自己的用户级属性,就是用tid作为线程对应属性集合的起始地址。

2.2 线程终止

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

  1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
  2.  线程可以调用pthread_ exit终止自己。
  3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程

pthread_exit函数
功能:线程终止
原型
void pthread_exit(void *value_ptr);
参数
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

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

pthread_cancel函数
功能:取消一个执行中的线程
原型
int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码 

2.3 线程等待

为什么要进行线程等待?

已经退出的线程,其空间没有被释放,仍然在进程的地址空间内,如果不进行等待,会造成类似进程的僵尸问题,造成内存泄漏。创建新的线程不会复用刚才退出线程的地址空间。

功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **value_ptr);
参数
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码

调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:

  • 1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  • 2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_CANCELED。
    #define PTHREAD_CANCELED ((void *) -1)
  • 3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  • 4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

首先我们先区别一下指针和指针变量

指针:就是地址,地址也就是指针,本质是一个数据
指针变量:本质是一个变量,一个变量它就首先需要在内存中开辟空间,空间里储存的是指针地址,就好比整数变量里储存的是整数。

那么我们知道线程的返回值是一个void类型的指针(void *),我们首先把它理解为一个地址,返回时将线程中的返回数据写入某个空间,再由主线程进行读取,整个流程为:

1.将void *类型的指针写入某个空间,于是储存空间中存放了一个地址,那么指向该空间的地址就为二级指针
2.将该储存空间的地址(二级指针)告诉主线程,主线程将其保存
3.主进程去读取该地址中存储的指针变量

所以接收返回值的变量为一个二级指针,线程的返回值就为该指针解引用,(*该指针)

2.4 线程分离

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
  • 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

pthread_detach(pthread_self());

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

// 这些用的都是原生线程库
// 1.线程谁先运行与调度器有关
// 2.线程一旦异常,可能导致整个进程退出
// 3.线程的输入和返回值问题
// 4.线程异常退出的理解 - 不用接收返回值,因为线程出异常整个进程就都崩溃了
void* pthreadRoutine(void* args)
{
    // pthread_cancel(pthread_self); // 不推荐
    int i = 0;
    int* data = new int[10];
    while(true)
    {
        cout << "新线程:" << (char*)args << "running...." << pthread_self() << endl;
        sleep(1);
        // data[i] = i;
        // int a = 10;
        // a = a/0;
        // if(i++ == 10) break;
    }
    cout << "new thread quit\n";
    return (void*)data;
}

int main()
{
    pthread_t tid; // 本质是一个地址
    pthread_create(&tid,nullptr,pthreadRoutine,(void*)"thread 1");
    // pthread_join(tid,nullptr);// 默认阻塞等待
    printf("%lu,%p\n",tid,tid);
    int i = 0;
    while(true)
    {
        cout << "主进程:" << "running...." << endl;
        sleep(1);
        if(i++ == 5) break;
    }
    // 1.线程被取消,join的时候,退出码是-1 #define PTHREAD_CANCELED ((void *) -1)
    // PTHREAD_CANCELED
    pthread_cancel(tid);
    cout << "pthread cancel,tid:" << tid << endl;
    int* ret = nullptr;
    pthread_join(tid,(void**)&ret);

    cout << "main thread wait done ... main quit  exit code:" << (long long)ret << endl;
    // for(int i = 0;i < 10;i++)
    // {
    //     cout << ret[i] << endl;
    // }
    // 3.线程在创建并执行时,也是需要被主线程等待的,如果不进行等待,会造成类似进程的僵尸问题,造成内存泄漏
    // while(true)
    // {
    //     cout << "主进程:" << "running...." << endl;
    //     sleep(1);
    // }

    return 0;
}

三、注意

1.线程不是越多越好,因为CPU要进行线程切换,一般线程数等于CPU核数
2.man clone #fork底层调用的函数,Linux提供的轻量级进程创建函数
   man vfork # 创建和父进程共享地址空间的子进程
3.线程谁先运行与调度器有关
4.线程一旦异常整个进程都会退出
5.全局变量是所有线程共享的,要是想要每一个线程各自拥有一个同名的全局变量,该全局变量需要用__thread修饰
6.在任意线程里面创建子进程都是以主线程PCB为模板拷贝
 

  • 25
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值