linux之线程概念

线程的概念

首先我们要进程和线程区分开来

什么是进程——专业点的说法就是加载到内存的一个执行流!而在linux里面本质点的来说就是内核数据结构+进程对应的代码和数据

每一个进程——都有自己独立的PCB,自己的进程地址空间,页表

进程地址空间决定了,进程能够看到的资源!

image-20230809145324576

虚拟内存里面决定了,进程能够看到的资源!

我们可以将进程看成是在窗户里的人!而虚拟内存就是房间里面的窗户

人要看到房间外面的风景(物理内存)就要通过窗户(虚拟内存)!

栈区,堆区,代码区,已初始化,未初始化数据,代码段等等,这些都是进程可以看到的资源!

我们是可以将一个进程的代码划成若干个部分,然后可以让另一个执行流去执行!

例如:我们可以使用fork,然后通过if判断,让父子进程执行不同的代码块!

但是如果是父子进程,就会发生写实拷贝!操作系统会给子进程也创建其PCB,虚拟地址空间,还有页表等等

线程是什么——一般的说法是进程内的一个执行流(这是一种宏观的说法,很抽象)

我们今天要说的线程——仅仅是单单指在linux下面的线程!==是一种具体的东西==

一个进程所对应的资源就是虚拟地址空间里面所划分的部分,如果今天我们将这些区域(栈区,堆区,代码区,已初始化/未初始化区域等等)都划分为若干个区域==然后再去创建**“进程”,但是这一次创建进程不再去进行创建所谓的虚拟地址空间,也不再去创建页表之类的资源而是只去创建一堆的PCB**==

==这些PCB指向的虚拟地址空间都不再去创建!而是都和父进程指向同一个的虚拟地址空间——这样子这些“进程”都可以通过这个虚拟地址空间看到资源!==

就好比,以前的房间里只有一个人!但是现在房间里进来了好几个人!而这些人都是用这个窗子来看外面的风景——只不过这个窗子原本是一个大窗户,现在被分成了好几个小窗户,每一个人都用自己的小窗户看向外面

这就相当于将进程的资源划分给若干个内部的PCB,然后我们让这些“进程”访问代码中的一部分,去执行代码

==我们将这种只创建PCB,从父进程中分配资源的执行流!我们称之为线程!==

image-20230809153104318

==现在我们能知道了!——一个进程所对应的资源,是能通过虚拟地址空间和页表来将自己的资源划分给特定线程的!==(或者说linux创建进程有两种方式一种是通过fork,将各种资源全部都拷一份!另一种是只创建PCB,然后用创建的PCB指向父进程的地址空间!)

我们可以通过虚拟地址空间+页表的方式对进程进行资源划分,当“进程”的执行==粒度==,一定要比之前的进程要细!

==站在CPU的角度——这些一个个的PCB(指向父进程的虚拟地址空间)和正常PCB是没有区别的!==

因为对于CPU而言,它只是去调度PCB,它并不关心这些PCB是不是有自己的独立代码和数据,它只关心去运行代码和数据,至于代码和数据是来自那种有独立的虚拟地址空间的PCB还是来自从父进程拿出一部分的代码和数据的PCB,它不关心

反正就是给CPU一个PCB,它就去跑!

为什么要怎么去设计呢?

如果OS专门的去设计“线程”,那么操作系统肯定要管理线程,那么就要给线程设计专门的数据结构来管理!——线程我们称之为TCB(线程控制块)也是一种结构体

然后因为线程在进程内部,所以就是在进程的内部用数据结构管理起来,例如:链表的形式将所有的TCB链接管理起来

而对于CPU来说,以前执行的都是进程的执行流,以进程为单位!现在又多了一个线程的执行流

那么CPU肯定要去区分一下什么是进程什么是线程,那么现在执行线程的方式就是先找进程,然后去进程里面找线程去调度!

==怎么干的操作系统是有的——例如我们最常使用的Windows操作系统!Windows操作系统内部是存在真正的TCB==

但是如果真的存在这种TCB,那么操作系统既要维护进程间的各种父子关系,如果进程里面还存在多个线程,那么还有保证线程和它所属的进程的关系——这样子就会让PCB和TCB的代码逻辑耦合程度非常高!而且很复杂!

而一个线程创建的根本目的就是要被执行,被调用度就要有id,状态,优先级,上下文,栈等等概念!——我们可以发现,单纯从调度的角度来看!线程和进程是有很多的重叠的地方!所以linux的设计者,就不想去给linux的"线程"去专门的设计对应的数据结构,而是复用PCB!用PCB来表示linux内部的“线程”

然后使用页表,将同一个虚拟地址空间的不同资源,通过不同的映射,来将资源交给不同的PCB

==我们将这些创建出来的PCB都称之为线程!==,linux下CPU不关心什么线程或者进程,反正都是同一种结构体task_struct

现在我们就可以重新理解一下这句话了——==线程是进程内的一个执行流!==

什么叫进程内的一个执行流呢?——即线程在进程的内部运行(在进程的地址空间运行),且拥有该进程的一部分资源(包括代码和数据)

==现在我们应该重新理解一下进程==

我们上面说过进程就是——内核数据结构+进程对应的代码和数据,这是没错的!

但是我们应该要说的更详细——是一堆的PCB(内核数据结构)+对应的虚拟地址空间+对应的页表+物理内存上对饮管道代码和数据整体==我们称之为进程!==

image-20230809162602688

在内核的视角:==进程就是承担系统分配资源的基本实体!==

我们创建进程的时候,所申请的一堆PCB,一堆的页表,还有加载到物理内存的代码和数据都花费了系统的资源,CPU的IO资源,内存的资源等等

==而这些资源的整体我们就称之为进程!==

而从内核的角度来说——==线程才是CPU调度的基本单位!==

以前我们说的进程其实是指——只有一个的task_struct!只是进程内的一块小资源!

==但是和我们现在讲的不冲突!因为无非就是从一个PCB,扩展为了多个PCB!==

**==以前也是承担系统资源的基本实体!只不过有一个执行流!!这个进程只有一个执行流也是可以的!==**和我们今天讲的不冲突!==是在今天是一个进程内部有多个执行流!(多个也包括了一个!)==

==对于CPU来说它所读取的task_struct就是一个轻量级进程!(CPU不管是线程还是进程!一律都当做轻量级进程即比一般的进程更加的轻量)==

总结

linux内核当中严格上来说——是没有真正意义上的线程的!linux使用进程PCB来模拟线程的!是一种完全属于自己的线程方案!

站在CPU的视角,讹谬个PCB,都可以称之为轻量级进程!

linux线程是CPU调度的基本单位!进程是承担分配系统资源的基本单位!

进程是用来整体申请资源!线程是用来伸手向进程要资源的!

如何线程内部去malloc或者new是属于线程还是进程呢?

举个例子:公司里面有个小组,一个组长和5个组员,公司将资源分配给小组,组长将工作分配给5个组员(就相当于5个执行流了),当小组资源不够用的时候,组长授意一个组员去申请资源!——那么组员肯定是要以小组的名义去申请!绝对不是以个人的名义!

==所以我们就可以知道!即使是线程new或者malloc出来的资源,也都是属于进程的!==

linux没有真正意义的线程!(即TCB结构),但是存在线程的概念!

使用PCB模拟线程的好处就是,可以完全的去复用曾经为PCB写的算法和各种数据结构,大大降低了维护成本!且可靠高效!

==但是这一套也不是没有缺点的!==

虽然说我们将linux下的线程说是轻量级的进程!——但是其实OS只认线程!用户(程序员)也只认线程!

但是因为linux没有真正的线程!所以linux是无法直接提供创建线程的接口!

而只能给我们提供创建轻量级进程的接口!(像是Windows因为有真正的TCB所以是有创建线程的接口)——因为linux没有线程这个概念,说是用轻量级进程模拟线程,这也是我们说的!是我们方便用来理解的!那么linux是如何提供一个内部没有这个概念的系统调用呢?这是不能的!

但是用户只认线程!但是linux只有轻量级进程!==所以为了解决这个问题——于是有了一个软件层——线程库!==

pthread_creat与线程库

像是一个国家——国家是以家庭为基本单位!而一个家庭里面又有很多的成员!每一个成员都肩负着不同的责任!例如:父亲赚钱,母亲做家务,孩子上学等等每个人做到事情都是不一样的!但是每一个人的目标是一样的!那就是完成把日子过好这个目的!

==国家就是——OS,家庭——就是进程!家庭成员——就是线程!==我们以往编写的程序都是只有一个“家庭成员”的程序!(即一个线程!)今天我们要编写的程序就是有多个“家庭成员”的程序(多线程)

==首先我们要真正的见一下线程!创建linux下线程的方式就是使用pthread_create函数!==

image-20230809205504795

这个函数就是创建一个新线程!

第一个参数——thread_t其实本质是一个整数类型! thread是一个线程id,是一个输出型参数,创建成功后会通过这个参数将id返回给我们!

第二个参数——attr 是线程属性!是一个输出型参数!有必要我们可以将线程的属性拿出来!一般可以设置为NULL

第三个参数——这是一个函数指针。返回值是一个void 类型,参数是一个void*类型的函数!*——可以让线程去执行函数指针指向的方法!

相当于把代码块划分为若干个执行流! 让不同线程分别执行!

第四个参数——和第三个参数的函数有关系!会将arg参参数这个作为,上面的函数的参数传给那个函数!

image-20230809210211569

image-20230809210829070

返回就是成功返回0,不成功则是返回错误码!

#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<cassert>
using namespace std;


//我们将创建出来的线程和main函数所在的线程不是父子关系!
//一般称之为新线程!
void* thread_routine(void* args)
{
    while(true)
    {
        cout << "我是新线程!我正在运行!"<<(char*)args <<endl;
        sleep(1);
    }
}
int main()
{
    pthread_t tid;
    int n = pthread_create(&tid,nullptr,thread_routine,(void*)"thread one");
    assert(n == 0);
    (void)n;

    //main所在的线程就是主线程!
    while(true)
    {
        cout << "我是主线程!我正在运行!"<< endl;
        sleep(1);
    }
    return 0;
}

image-20230809211522204

==我们发现出现问题了!——为什么 pthread_create这个函数会出现问题呢?我们发现没有定义这个函数?为什么?==

因为我们说过!linux根本没有给我们提供所谓创建线程的接口!都是创建轻量级进程!

那么pthread.h这个头文件存在的意义是什么?我们一般不都是包了头文件就可以使用函数接口吗?——首先linux没有真正意义上的系统级的线程接口!这个pthread是库给我们的!(是什么库呢?)

==那么既然是库给我们的我们就得去找个库要!——这个库就是pthread==

image-20230809212853097

在g++里面指定库的名字之后!那么我们就能编译成了!

image-20230809213041290

==这个库就是真正的原生线程库!==

因为操作系统无法给我们系统级的线程接口!但是我用户却必须要有个线程接口来进行调用!(如果我们自己手动的去调用轻量级进程然后模拟线程那样子学习成本太高了!)所以操作系统在用户和系统之间!提供了一个由程序员编写好的==用户级的线程库!==

==这个线程库向上提供了一大堆的线程接口!向下会将我们对线程的所有操作!转化为对进程的所有操作——这就是pthread库!==

这个pthread库是任何操作系统都必须默认携带这个库的!所以叫做原生线程库!(无系统调用之名,但是行系统调用之实)

image-20230809214204554

我们可以看出来这一定是一个多执行流的进程!因为单执行流不可能两个while循环在进行!

image-20230809214355403

==我们可以也可以看出来确实只有一个进程!==

image-20230809214602498

只要杀死这个进程,无论里面有多少个线程程序都会终止!

==那么我们该如何看到线程呢?——不是说线程的本质就是轻量级进程么?==

ps -aL——这个指令就是用来查看轻量级进程的!L——Light

image-20230809215154387

我们还能发现!第一个线程的PID和LWP是一样的——这就是主线程,不一样的就是新线程!

因为线程才是CPU调度的基本单位!所以每一个线程一定要有其对应的id用于识别!——==所以CPU调度是以LWP这个标识符来表示特定的执行流!而不是PID!==

我们能不呢使用kill指令杀死LWP呢?——==不能因为信号是个进程发的!是给这个整体发的!不是给单独一个线程发的!==

那么我们上面的使用thread_creat创建出来后,返回的tid参数是不是就是这个lwp呢?我们可以打印一下看看

//main所在的线程就是主线程!
while(true)
{
    cout << "我是主线程!我正在运行!"<< tid <<endl;
   sleep(1);
}

image-20230811094155896

这是一个什么数字?——如果我们用十六进制的方式来打印呢?

//main所在的线程就是主线程!
while(true)
{
    char tidbuffur[64];
    snprintf(tidbuffur,sizeof tidbuffur,"0x%x",tid);
    cout << "我是主线程!我正在运行!" << tidbuffur << endl;
    sleep(1);
}

image-20230811094743551

==这个看起来像什么呢?——地址!这就是一个地址!==

这个是一个什么地址我们后面会说!

==但是我们可以得出一个结论!——就是tid和我们系统里面查出来的LWP是不一样的!==

线程的资源特征

线程一旦被创建!几乎大部分的资源都是被所有线程共享的!

#include<iostream>
#include<pthread.h>
#include<cstdio>
#include<unistd.h>
#include<cassert>
#include<string>
using namespace std;

string fun()
{
    return "我是一个函数";
}

int g_val = 999;//这是一个全局变量!
void* thread_routine(void* args)
{
    while(true)
    {
        cout << "我是新线程!我正在运行!" << (char *)args << " " << "g_val :" << g_val << " &g_val :" << &g_val <<" "<< fun() << endl;
        sleep(1);
    }
}
int main()
{
    pthread_t tid;
    int n = pthread_create(&tid,nullptr,thread_routine,(void*)"thread one");
    assert(n == 0);
    (void)n;

    //main所在的线程就是主线程!
    while(true)
    {
        sleep(1);
        cout << "我是主线程!我正在运行! "<< "g_val : "<< ++g_val<<" &g_val :" << &g_val << " " <<fun() <<endl;
    }
    return 0;
}

image-20230811101700674

我们可以发现无论是主线程还是新线程,都可以调用函数!(**所以函数是共享的!**)

对于一个全局变量!——只要主线程修改了这个变量!那么新线程也会一同被修改!(**资源也是贡献的!**)

如果主线程申请了一个堆空间!虽然即使只有主线程能够访问!——但是这个资源也是共享的!

==所以线程之间想要进行数据交互是很容易的!——不像是进程间通信那样麻烦!==

虽然线程的大部分资源都是共有的!——==但是线程也一定要有自己私有资源(内部属性!)==

就像是一个家庭里面,虽然大部分的东西都是大家一起用的!但是每一个人自己的都有自己的小秘密不和别人说

  1. 首先线程肯定要被调度!——那么就一定要有独立的线程id,独立的优先级,独立的状态等等PCB内部的所有属性都应该全是私有的!==即PCB属性私有!==
  2. 线程要进行切换——那么线程有可能没有跑完!那么肯定要进行上下文保存!则肯定要有自己独立的上下结构!即==上下文结构私有!==(说明线程是动态特征的)
  3. 每一个线程都要独立的去运行——那么每一个线程都要有独立的栈结构==即栈结构私有==(也是说明线程动态属性的特征)

这里提问一个问题——进程地址空间的栈区只有一个,CPU里面也只有有ebp和esp这两个寄存器来指向栈底和栈顶表示栈区的范围,那么是如何保证每一个线程都自己独立的栈结构的呢?

如果面试官问什么资源是共享的!那么凡是地址空间里面的大部分都是共享的!

如果问是私有的!我们要把2,3都说出来那么就可以得到很高分!(10分得8分)

修改

线程的资源私有与公用总结

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

  1. 文件描述符表
  2. 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)

这就是为什么我们向一个进程发送一个例如kill -9信号,会让所有的线程都一起关闭!

  1. 当前工作目录

  2. 用户id和组id

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

  1. 线程ID
  2. 一组寄存器
  3. errno
  4. 信号屏蔽字
  5. 调度优先级等待

线程的优缺点总结

优点

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

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

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

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

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

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

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

计算密集型应用——主要使用的是CPU资源!例如加密,界面,平时写的算法等等

例如对文件打包压缩

I/O密集型应用——主要访问外设,访问磁盘,显示器,网络!

例如下载文件

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

如果一个10g的文件,我们要压缩,如果是单线程,那么只能在一个CPU上跑

但是如果是多线程,我们可以将10g分成两个5g,同时在两个CPU上跑!这样子会更快!

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

例如:我们网络很好,所以用迅雷同时下载10个文件,那么此时就是多线程在运行!

将所有的IO时间进行重叠!

缺点

性能损失

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

线程不是越多越好!——在计算密集型总,一般创建的==线程数最好和我们CPU的核数是一样的,创建的进程数应该最好和CPU个数是一样的==

如果只有一个CPU,且CPU只有一个核心!——那么最快的就是一个线程就可以了!

健壮性降低

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

对于进程而已,一个进程挂掉了!是不会影响另一个进程的!但是一个线程却可能影响另一个的线程!

缺乏访问控制

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

这句话是什么意思的呢?就像是我们上面写的!一个全局变量,一个线程修改了,另一个线程也会立即看到!可以减少我们通信的成本!——但是这也是一个双刃剑,但是有可能因为==一个线程正在访问从而影响另一个线程!这就是缺乏访问控制!==

编程难度提高

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

线程异常

#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<string>
using namespace std;
void* start_routie(void* args)
{
 string name = static_cast<const char *>(args);//安全的强制类型转换
 while(true)
 {
     cout << "new thread create success ,name : " << name <<endl;
     sleep(1);
     int* p = nullptr;
     *p = 0;
 }
}
int main()
{
 pthread_t tid;
 pthread_create(&tid,nullptr,start_routie,(void*)"one thread");
 while(true)
 {
     sleep(1);
     cout << "main thread create success ,name : main"<<endl;
 }
 return 0 ;
}

image-20230811173217677

==我们发现一个线程出现问题后,会影响其他线程的正常运行!——这就是健壮性(鲁棒性)较差==

为什么呢?——因为出现段错误后,系统会发生信号给进程,让进程终止!(信号是发给整个进程的!)

我们也可以用另一种角度来理解!线程是进程的一部分!那么线程内部出现了错误!那么其实不就是进程内部出现了错误?那么线程出现了异常,其实不就是进程出现了异常!那么当进程被终止之后,其他线程的赖以生存的资源也都不见了!那么线程也应该要退出!

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

进程和线程的区别

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

进程切换:就要切换页表(用户级页表),切换PCB,切换上下文,切换虚拟地址空间!

如果是线程(轻量级进程)切换:就要切换PCB,切换上下文

但是其实无论是切换页表还是切换虚拟地址地址空间,切换的成本都不高!但是为什么要说线程切换会让操作系统做的工作要少很多呢?

我们就要去认识一下什么叫cache

image-20230811111458018

当一个进程正咋运行的时候

image-20230811111843559

所以CPU运行进程的时候,不是从内存里面拿数据的!而是从cache里面拿的!

==而一个运行很稳定进程,cache里面一定缓存了很多的**热点数据!**当线程切换的时候,这些热点数据本来就是被线程所共享的!所以线程切换的时候,cache里面的值是不用切换的!==

但是如果是进程呢?——a进程缓存了一大堆的数据!一旦切换到b进程!a进程的cache里面的数据就全部失效!那么就得重新缓存数据!等下一个进程重新回来的时候,又得重新的缓存(热点数据是指高概率命中的数据,是需要进程跑一段时间才能出来的!那么在之前cache都是不能使用的!只能访问内存!这样子效率就低了)

==所以线程和进程间切换的高成本!主要就是体现在cache上!==

线程切换cache不用太更新,但是进程切换,就要全部更新!

==进程和线程的关系==

image-20230811174532501

clone

上面操作系统是没有创建线程的接口!只有创建轻量级进程的接口!而这个接口就是——clone!

image-20230811175238310

==这个clone函数既可以创建一个进程,也可以创建一个轻量级进程——说白了就是创建执行流动时候要不要共享地址空空间!像是创建子进程的fork函数其实底层也是clone==

还有一个vfokr函数!——也是创建子进程!

image-20230811175359044

而这个函数创建出来的子进程,是会和父进程共享地址空间的!——其实就是一个轻量级进程!

无论是clone还是vfork我们都很少去用!——谁去用?库去用!(pthread库这样的,将我们的线程操作转化为进程操作)

fn参数——就是新的执行流要执行的代码!

child_stack参数——其实就是线程的栈