Linux操作系统多线程

Posted Ricky_0528

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux操作系统多线程相关的知识,希望对你有一定的参考价值。

文章目录

1. 线程概念

线程:是在进程内部运行的一个执行分支(执行流),属于进程的一部分,粒度要比进程更加细和轻量化

内部:线程在进程的地址空间内运行

执行分支:CPU调度的时候只看PCB,如果PCB曾被指派过指向方法和数据,那么CPU就可以直接调度

线程创建的时候,只创建task_struct,与当前进程共享同一个地址空间,当前进程的资源(代码+数据)被划分为若干份,让每一个PCB使用

一个PCB就是一个需要被调度的执行流

Linux中没有专门为线程设计PCB,而是用进程的PCB来模拟线程,这样就不需要维护复杂的进程与线程之间的关系,不用单独为线程设计任何算法,直接使用进程的一套相关的方法,OS只需要聚焦在线程间的资源分配上就可以了

在没有多线程之前的进程,是内部只有一个执行流的进程,而现在的进程,内部可以具有多个执行流

CPU创建进程的成本(时间+空间)非常高,需要使用到的资源非常多(0 -> 1),因而应该减少进程的创建,去使用多线程

从内核的角度,进程是承担分配系统资源的基本实体!线程是CPU调度的基本单位,承担进程资源的一部分的基本实体,是进程划分资源给线程


什么是线程

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

Linux因为使用的是进程模拟的线程,所以Linux下不会给我们提供直接操作线程的接口,而是给我们提供在同一个地址空间内创建PCB的方法,分配资源给指定的PCB接口,这对用户其实并不是很友好,所以就有系统级别的工程师,在用户层对Linux轻量级进程接口进行封装,给我们打包成库,让用户直接使用库接口,这被称为原生线程库

线程的优点

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

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

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

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

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

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

    计算密集型应用:加密、大数据运算等主要使用CPU资源的应用

    线程越多越好嘛?并不一定,如果线程太多,会导致线程被过度调度切换,这是有成本的

  • I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作

    I/O密集型应用:网络下载、云盘、ssh、在线直播等使用内存和外设的I/O资源

    线程越多越好吗?也不一定,但I/O允许多一些线程,因为大部分时间是在等待I/O就绪的,多个线程可以让I/O等待的时间重叠,从而缩短了时间

线程的缺点

  • 性能损失

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

  • 健壮性降低

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

  • 缺乏访问控制

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

  • 编程难度提高

    编写与调试一个多线程程序比单线程程序困难得多

线程异常

  • 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
  • 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

线程用途

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

进程 vs 线程

所有的轻量级进程(可能是线程)都是在进程的内部运行(地址空间:标识进程所能看到的大部分资源)

进程:具有独立性,可以共享部分资源(管道、ipc资源)

线程:大部分资源是共享的,可以有部分资源是”私有”的,如下

  • 进程和线程
    • 进程是资源分配的基本单位
    • 线程是调度的基本单位
    • 线程共享进程数据,但也拥有自己的一部分数据
      • 线程ID
      • 一组寄存器
      • errno
      • 信号屏蔽字
      • 调度优先级
  • 进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境
    • 文件描述符
    • 每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
    • 当前工作目录
    • 用户id和组id

进程和线程的关系

验证

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

void *thread_run(void *args)

    const char *id = (const char*)args;
    while(1) 
        printf("我是%s线程, pid: %d\\n", id, getpid());
        sleep(1);
    


int main()

    pthread_t tid;
    pthread_create(&tid, NULL, thread_run, (void*)"thread 1");

    while(1) 
        printf("我是main线程,pid: %d\\n", getpid());
        sleep(1);
    

    return 0;

使用pthread需要在编译时-l加上链接库pthread

gcc mythread.c -o mythread -lpthread

我们发现两个执行流的进程pid是一样的

说明此时实际上只有一个进程,但进程内部有两个执行流,使用如下命令查看各个轻量级进程

ps -aL

单独一个进程的时候其PID=LWP,所以在多线程的时候,PID=LWP的那个线程为主线程

Linux操作系统进行线程调度的时候,看的是LWP,这个LWP是内核LWP

一般把属于同一个进程内的一批线程称为同一个线程组,线程组的组ID为当前进程的PID

2. 线程控制

POSIX线程库

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

2.1 创建线程

thread:输出型参数,返回创建出来的线程ID

attr:设置线程的属性,一般不需要设置,置为NULL表示使用默认属性

start_routine:为一个函数地址,线程启动后要执行的函数

arg:传给线程启动函数的参数

返回值:创建成功返回0,创建失败返回一个错误码

注意点

  • 传统的一些函数是成功返回0、失败返回1,并且对全局变量errno赋值以指示错误
  • pthreads函数出错时不会设置全局变量errno(大部分其他POSIX函数会这样做),而是将·错误代码通过返回值返回
  • pthreads同样也提供了线程内的errno变量,以支持其他使用errno的代码,对于pthreads函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量的开销更小

2.2 线程ID

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

获取子线程的ID

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

void *thread_run(void *args)

    while(1) 
        printf("我是新线程[%s],我的线程ID为: %lu\\n", (const char*)args, pthread_self());
        sleep(1);
    


int main()

    pthread_t tid;
    pthread_create(&tid, NULL, thread_run, (void *)"new thread");
    
    while(1) 
        printf("我是主线程,我创建的线程ID为: %lu,我的线程ID为: %lu\\n", tid, pthread_self());
        sleep(1);
    

新线程与主线程创建出来的新线程ID是一样的,说明我们创建出来了线程,但是却与查看到的内核LWP不一样

一次性创建一批线程

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

void *thread_run()

    while(1) 
        sleep(3);
    


int main()

    pthread_t tid[5];
    for (int i = 0; i < 5; i++) 
        pthread_create(tid + i, NULL, thread_run, NULL);
    
    
    while(1) 
        printf("我是主线程,我的线程ID为: %lu\\n", pthread_self());
        printf("##########begin##########");
        for (int i = 0; i < 5; i++) 
            printf("线程[%d]的ID是: %lu\\n", i, tid[i]);
        
        printf("##########end##########");
        sleep(1);
    

在这里thread_run函数就被多次重入了

Linux下进程地址空间布局

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

2.3 线程等待

一般而言,线程也是需要被等待的,如果不等待,可能会导致类似于“僵尸进程”的问题

thread:线程ID

retval:输出型参数,用来获取新线程退出的时候,函数的返回值,因为返回的是一个一级指针,所以需要用二级指针接收

创建线程时start_routine的类型是(void *),即线程执行的函数的返回值为一个一级指针

返回值:等待成功返回0,失败返回错误码

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

  • 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值
  • 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED
  • 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数
  • 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数

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

void *thread_run()

    while(1) 
        printf("我是子线程,线程ID: %lu\\n", pthread_self());
        sleep(3);
        break;
    

    return (void *)1;


#define NUM 1

int main()

    pthread_t tid[NUM];
    for (int i = 0; i < NUM; i++) 
        pthread_create(tid + i, NULL, thread_run, NULL);
    
    
    void *status = NULL;
    for (int i = 0; i < NUM; i++) 
        pthread_join(tid[i], &status);
      
    printf("ret: %d\\n", (int)status);

    return 0;

这样单纯的使用pthread_join只能处理代码跑完结果不对的情况,但如果代码出现了异常,根本就执行不到pthread_join

2.4 线程终止

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

  • 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用了exit

    exit是终止进程,如果你只是想终止一个线程的话不要在该线程中调用

  • 线程可以调用pthread_exit终止自己

    主线程调用pthread_exit函数也不会使整个进程退出,不影响其他线程的执行

    但是其子线程就会成为僵尸进程,这是需要进程等待pthread_join

  • 一个线程可以调用pthread_cancel终止同一进程中的另一个线程

pthread_exit

retval:表示线程退出状态,可以传NULL

注意事项

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

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

void *thread_run()

    while(1) 
        printf("我是子线程,线程ID: %lu\\n", pthread_self());
        sleep(3);
        break;
    

    pthread_exit((void *)1);


#define NUM 1

int main()

    pthread_t tid[NUM];
    for (int i = 0; i < NUM; i++) 
        pthread_create(tid + i, NULL, thread_run, NULL);
    
    
    void *status = NULL;
    for (int i = 0; i < NUM; i++) 
        pthread_join(tid[i], &status);
    
    printf("ret: %d\\n", (int)status);

    return 0;

pthread_cancel

用pthread_cancel取消目标线程

thread:线程ID

返回值:成功返回0,失败返回错误码

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

void *thread_run()

    while(1) 
        printf("我是子线程,线程ID: %lu\\n", pthread_self());
        sleep(1);
    


#define NUM 1

int main()

    pthread_t tid[NUM];
    for (int i = 0; i < NUM; i++) 
        pthread_create(tid + i, NULL, thread_run, NULL);
    
    
    printf("wait sub thread\\n");
    sleep(5);

    printf("sub thread cancelled\\n");
    pthread_cancel(tid[0]);

    void *status = NULL;
    for (int i = 0; i < NUM; i++) 
        pthread_join(tid[i], &status);
    
    printf("ret: %d\\n", (int)status);

    return 0;

取消线程,函数的返回值为-1,为常量PTHREAD_ CANCELED

如果我们在子线程中杀掉主线程,子线程会继续运行,但主线程会进入defunct状态,就类似于僵尸进程(不建议这样)

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

pthread_t g_tid;

void *thread_run()

    while(1) 
        printf("我是子线程,线程ID: %lu\\n", pthread_self());
        sleep(1);
        pthread_cancel(g_tid);
     


#define NUM 1

int main()

    g_tid = pthread_self();
    pthread_t tid[NUM];
    for (int i = 0; i < NUM; i++) 
        pthread_create(tid + i, NULL, thread_run, NULL);
    
    
    printf("wait sub thread\\n");
    sleep(50);

    printf("sub thread cancelled\\n");
    pthread_cancel(tid[0]);

    void *status = NULL;
    for (int i = 0; i < NUM; i++) 
        pthread_join(tid[i], &status);
    
    printf("ret: %d\\n", (int)status);

    return 0;

2.5 线程分离

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

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

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

pthread_t g_tid;

void *thread_run()

    pthread_detach(pthread_self());
    while(1) 
        printf("我是子线程,线程ID: %lu\\n", pthread_self());
        sleep(1);
        break;
    

    return (void *)1;


#define NUM 1

int main()

    pthread_t tid[NUM];
    for (int i = 0; i < NUM; i++) 
        pthread_create(tid + i, NULL, thread_run, NULL);
    
    
    printf("wait sub thread\\n");
    sleep(1); // 重要,一定要让线程先分离再等待

    printf("sub thread cancelled\\n");

    int ret = 0;
    void *status = NULL;
    for (int i = 0; i < NUM; i++) 
        ret = pthread_join(tid[i], &status);
    
    printf("ret: %d, status: %d\\n", ret, (int)status);

    sleep(3);

    return 0;

一个线程被设置为分离之后,绝对不能再进行join了,主线程不退出,新线程在业务处理完毕后退出

3. 线程同互斥与同步

因为多个线程之间是共享地址空间的,也就是很多资源都是共享的

优点:通信方便 缺点:缺乏访问控制

线程安全问题:因为一个线程的操作问题,给其他线程造成了不可控,或者引起崩溃、异常、逻辑错误等现象

创建一个函数没有线程安全问题的话,就不能使用STL、malloc、new等会在全局内有效的数据

访问控制:因为全都是局部变量,线程有自己独立的栈结构

  • 临界资源:凡是被线程共享访问的资源都是临界资源(多线程、多进程打印数据到显示器)
  • 临界区:在代码中访问临界资源的代码(代码中并不是所有的代码都会访问临界资源,访问临界资源的代码区域才称为临界区)
  • 对临界区进行保护的功能,本质就是对临界资源的保护,方式是互斥或同步
  • 互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源),就可以称为互斥
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
  • 同步:让访问临界资源的过程在安全的前提下(一般都是互斥和原子的),让访问资源有一定的顺序性

互斥量

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互
  • 多个线程并发的操作共享变量,会带来一些问题

一个模拟抢票的例子

#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

int tickets = 10000; // 这里的tickets是不安全的

void *thread_run(void 以上是关于Linux操作系统多线程的主要内容,如果未能解决你的问题,请参考以下文章

java 多线程怎么深入?

Java多线程-静态条件与临界区

多线程环境,线程安全知识点Violatile和synchronized

Linux 多线程:线程安全之同步与互斥的理解

Linux下各种锁的理解和使用及总结解决epoll惊群问题(面试常考)

Linux并发与同步专题