Linux学习记录——이십사 多线程(1)


1、以Linux角度理解

创建一个进程时,会有pcb结构体,CPU中有很多寄存器,存有很多各个类型的数据,比如其中一个就指向进程pcb,只要pcb一换,那么指向的进程就不一样了。假如再创建进程的时候只创建pcb,而不是像之前创建子进程那样还有地址空间等的操作,这些新的pcb和最一开始的pcb指向同一个地址空间,我们可以在代码中写上几个不同的函数,然后每个pcb都可以访问到地址空间的代码区运行不同的代码,CPU指向哪一个pcb就运行哪一个函数。像这样在进程内创建出来的多个pcb就称作一个个线程,这样运行的方法也就是并发执行。

由于一个线程执行的是一个进程的一部分,所以线程的执行粒度比进程更细。对于CPU来说,它分不清楚是线程和进程,因为它只看pcb。线程是进程内部的一个执行流。线程的切换只需要切换pcb,不需要切换地址空间和页表。既然CPU不知道是不是线程,那切换时怎么确定要不要换地址空间?实际上切换的工作是操作系统做的,CPU执行系统的代码来切换。

CPU有一个硬件cache,是高速缓存。一个进程在运行时,如果访问到了100行代码,也有可能访问到101行,那为了加快速度,cache就会把100行附近的数据,都先缓存进来。在切换线程时,这个缓存数据不用变化,因为都是同一个进程,但是切换一个不同地址空间的进程时,这个缓存数据就需要设为失效,清理掉,然后再缓存新进程的数据。所以线程的调度成本更低。

现在所知道的线程其实应当是执行流,而包含了多个执行流,地址空间和页表的整个部分是进程,进程是承担分配系统资源的基本实体。进程内部可以多个task_struct,也就是进程结构体。在CPU角度来看,它只认pcb,所以线程是CPU调度的基本单位。

2、并不是所有的操作系统都这样管理

可以看出来,线程数量一定比进程多,操作系统需要管理线程,同样地,需要先描述再组织,有些操作系统是有线程结构体的,也就是TCB,线程控制块。TCB属于PCB,需要维护两者之间的关系,加上调度进程,调度线程,如果这样设计操作系统,这个系统就很复杂,但windows就是这样做的,它的内核有真线程。

Linux不是这样设计的。在实际应用中会发现调度线程和调度进程有很多的相同点,所以Linux的TCB就是直接复制了PCB,代码上复用PCB的结构体,用PCB模拟线程的TCB,所以Linux没有真正意义上的线程,而是用进程方案模拟的线程,这一点也决定了Linux系统可以一直不关机,但是windows如果一直不关机,就完了。

复用代码和结构这一做法让线程执行更简单,并且好维护,效率更高,也更安全,所以Linux才能一直不间断地运行。一款系统,使用最频繁的功能,除了系统本身,就是进程。

Linux的调度执行流叫做轻量级进程。创建线程的函数时pthread_create,下面会写。

在这里插入图片描述

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <pthread.h>

void* thread1_run(void* args)
{
    
    
    while(1)
    {
    
    
        printf("我是线程1, 我正在运行\n");
        sleep(1);
    }
}

void* thread2_run(void* args)
{
    
    
    while(1)
    {
    
    
        printf("我是线程2, 我正在运行\n");
        sleep(1);
    }
}

void* thread3_run(void* args)
{
    
    
    while(1)
    {
    
    
        printf("我是线程3, 我正在运行\n");
        sleep(1);
    }
}

int main()
{
    
    
    pthread_t t1, t2, t3;
    pthread_create(&t1, NULL, thread1_run, NULL);
    pthread_create(&t2, NULL, thread2_run, NULL);
    pthread_create(&t3, NULL, thread3_run, NULL);
    while(1)
    {
    
    
        printf("我是主线程,我正在运行\n");
        sleep(1);
    }
}

可以用ps -aL | head -1 && ps -aL | grep 可执行程序名来查看线程,其中的LWP就是轻量级进程ID,但是它和PID相同,那么系统如何辨别这两个?系统调度的时候看的是LWP,如果只有一个线程,那么自然看的就是PID。如果要切换的PID和现在LWP不一样,那么这就是跨进程调度,那就需要切换地址空间和页表了;相同就是进程内调度。

3、页表和物理内存

虚拟地址的空间的基本单位是字节,那么32位机器下虚拟地址空间是4G,它有多少地址?4G就是2 ^ 32次方个字节。虚拟转物理,需要页表来转换。页表是一种软件,它肯定要占据一定内存,那么页表有多大,它要维护2 ^ 32次方个虚拟空间的字节,还有对应物理内存,它要维护kv关系,除此之外,页表还有其他属性,1个地址占据4个字节,那么虚拟和物理就是8个字节,假设文件属性那一列也是4字节,总共12个字节,那么就是2 ^ 32 * 12,48G!这可能吗?这不可能,所以这点是错误的。

物理内存在寻址的单位是1字节,我们也可以一个个字节访问内存,也可以使用比特位,但物理内存并不是一个个字节排列起来的。物理内存会高频地和磁盘进行交互,磁盘的速度会比较慢,那么物理内存如果规规矩矩地按照字节为单位来访问数据的话,效率就不高了;如果一次IO就搬运很多数据,会比多次IO搬运少量数据更高效。所以系统在和磁盘这样的设备进行IO交互的时候,是要以块为单位的,根据之前写的文件系统,这个单位是4kb,8个扇区,当然不同系统也会有不同。那如果想更改一个比特位呢?这也必须IO4kb。由于要IO4kb,文件等数据在磁盘存储时也是按照4kb为单位来存储的,比如9kb的数据就是三个4kb,这样IO时就可以很块地找到要访问的区域,并且找到头尾,返回回来。文件包括动静态库和可执行程序这些,它们也是按照4kb为单位向上取整存放的。

磁盘这样管理,物理内存和操作系统也一样。内存实际进行管理的时候,就已经被划分成了一个个4kb,所以内存管理的本质就是将磁盘中特定的4kb的块(数据内容)放入到某一个物理内存的4kb的空间(数据保存的空间),物理内存中这一个4kb的内容叫做页,这个空间叫做页框,磁盘中的每一个4kb空间叫做页帧。操作系统对于物理内存的页也会管理,先管理再组织,会有一个page的结构体,里面有页的各种属性。对于page的组织,系统用的是数组。这个数组以字节为基本单位,有1048576个数组元素。当找到指定内存没有被使用,那么申请内存时就会把它置为1,也就申请上了。操作系统的管理方式还有其他种,比如LRU,伙伴系统。

虽然不管怎么样,一次IO会传过来4kb数据,但只访问1字节也要传4kb,这是不是有些浪费啊?实际上操作系统不只是传了4kb。系统有一个局部性原理,是现代计算机预加载数据的理论基础,它会提前加载我们正在访问的数据的相邻或附近的数据,所以会通过预先记载要访问的数据的附近来减少未来IO的次数。

回到最一开始的一个问题,页表真的有48GB?!页表不可能是这样的。一个虚拟地址是32个比特位,它传进页表时不是被整体使用的,它是被划分成10+10+12的。系统会拿出前10个比特位充当key去页表查找value,那么现在这个页表应当是2 ^ 10个行,然后会找到对应的二级页表;再拿10个比特位,去这个二级页表查找,这个二级页表最多也是2 ^ 10方的行,这时候查找的value是页框地址,刚才写到管理页的数组有1048576个元素,算到具体每一个,二级页表里的value需要有20个比特位,对应的就是物理内存的页框的起始地址;最后的12个比特位是页内偏移,页框的起始地址+页内偏移就能找到任意一个起始字节地址。

12个比特位有4095个数据,所以IO的基本单位是4kb。操作系统定位任意一个内存字节位置的方法就是基地址+偏移量。

这样管理后,页表最多有2 ^ 10个数据,也就是1M,每个地址4字节,所以就是最多就是4M。用户不可能访问到所有的物理内存,页表只在用的时候才创建,用多少创建多少,所以实际上页表会更小。

刚才写的一级页表叫页目录,二级页表叫页表项,也有不同的叫法,但都是一个东西。

我们现实中用语言的时候,很少直接访问比特位或者只有一个字节的变量,比如定义好几个int类型的变量,数组,类对象等等,难道就是这样从1个字节开始访问?任何一个对象或者变量,可能存在多个字节,但是取地址的时候,永远只拿到了一个地址数字,这个地址就是起始地址,加上类型,也就是偏移量就是整个地址。

实际在申请malloc内存的时候,系统只在虚拟地址空间上申请,当用户真正访问时,系统才会自动申请或者填充页表,申请具体的物理内存。系统是怎么做到自动填充页表的?通过缺页中断。一旦要开始转换的时候,MMU会发现这时候页表还不存在,就会向寄存器发送中断信号,系统执行默认动作来创建页表,申请物理内存。同理,要访问磁盘数据时,文件会先加载一部分,然后实际访问的时候再继续加载。

关于页表和物理内存的操作执行流不关心这些,它们是通过页表,缺页中断等来进行耦合的。

页表不止是这些,在页表项中还有其他属性,比如是否命中代表着物理内存是否已经存在要访问的数据,RWX代表对访问数据的权限等。

在之前的代码中,char* s的内容不允许修改,它存储在字符常量区,只允许被读取。因为s保存的是指向的字符串的虚拟起始地址,*s在寻址的时候会有虚拟到物理的转化,那么就一定要查页表,会对你的操作进行权限审查,发现这个操作非法,MMU就会发生异常,系统识别到异常后转化成信号,将信号发送给目标进程,在进程从内核态转为用户态的时候进行信号处理,进程就会终止。

4、线程优缺点

优点:
1、创建一个新线程的代价要比创建一个新进程小得多
2、与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
3、线程占用的资源要比进程少很多
4、能充分利用多处理器的可并行数量
5、在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
7、计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现(加密解密,文件压缩和解压等和算法相关的,但是线程数量要合适才好,进程/线程和cpu的个数/核数一致)
8、I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作(下载,上传,IO主要消耗IO资源,磁盘的IO,网络的带宽。在这里线程也不是越多越好,可以比较多)

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

要使用线程的话,在gcc最后加上-lpthread。线程创建函数是pthread_create,变量类型是pthread_t。

在多线程程序中,任何一个线程崩溃都会导致进程崩溃。以系统角度来看,线程是进程的执行分支,线程被杀掉就是进程被杀掉;从信号角度看,页表转换的时候MMU识别写入权限的时候没有通过,系统就会拿到异常给进程发信号,信号是以进程为主的,会给进程的每个线程写异常,所以全部挂掉。

因为一个进程内执行流看到的资源是通过地址空间看到的,多个LWP看到的是同一个地址空间,所以所有的线程可能会共享大部分数据。

5、进程和线程的区别

线程也有属于自己的一部分数据:线程ID,一组寄存器,栈,errno,信号屏蔽字,调度优先级。这里面最重要的就是一组寄存器和独立的栈结构。寄存器与线程的动态切换有关;栈与线程自己的数据有关。线程共享的数据有这些:文件描述符表,每种信号的处理方式,当前工作目录,用户ID和组ID,也就是数据区代码区堆区都可以共享。

6、线程接口

以系统角度来看,Linux没有真正意义的线程,而是用进程模拟的线程(LWP),所以Linux不会提供直接创建线程的系统调用,他会给我们最多提供创建轻量级进程的接口。

以用户角度来看,用户只认线程,那么库就对下将Linux接口封装,对上给用户提供进行线程控制的接口,这种库叫做用户级线程库,这个库也叫pthread库,这个库任何系统都需要自带,在Linux中这个库叫做原生线程库。

下面开始写一些代码

1、pthread_create.

线程的创建是pthread_create。

在这里插入图片描述

第一个参数是线程ID,是一个输出型参数;attr是线程属性,一般设置为NULL;第三个参数是一个函数指针,是新线程执行的函数、方法;arg就是这个函数的参数。

使用线程接口时,makefile的g++后面必须写上-lpthread。

threadtest:thread.cc
    g++ -o $@ $^ -lpthread
.PHONY:clean
clean:
    rm -f threadtest
#include <iostream>
#include <unistd.h>
#include <phread.h>

void* thread_run(void* args)
{
    
    
    while(true)
    {
    
    
        cout << "new thread running" << endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    
    
    pthread_t t;
    pthread_create(&t, nullptr, thread_run, nullptr);
    while(true)
    {
    
    
        cout << "main thread running, new thread id: " << t << endl;
        sleep(1);
    }
}

当正式打印时,用指令ps -aL | head -l && ps -aL | grep threadtest,会发现我们看到的LWP,和程序打印出来的结果不一样,那为什么会不一样,各自存在的意义是什么?

下面会写到这个问题的答案。代码中我们创建了新线程,实际运行的时候运行顺序由调度器决定。

创建一批线程的话

void* thread_run(void* args)
{
    
    
    char* name = (char*)args;
    while(true)
    {
    
    
        cout << "new thread running" << endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    
    
    pthread_t tids[NUM];
    for(int i = 0; i < NUM; i++)
    {
    
    
        char tname[64];
        snprintf(tname, sizeof(tname), "thread-%d", i + 1);
        pthread_create(tids + i, nullptr, thread_run, tname);
    }
    while(true)
    {
    
    
        cout << "main thread running, new thread id: " << endl;
        sleep(1);
    } 
    return 0;   
}

tname相当于十个线程指向同一个主线程的临时空间,tname拿到的是起始地址,每一次这个数组都会被覆盖,所以最后只会留下最后一个的地址,打印出来会看到十个线程打印出来的东西都是第十个。要打印出全部线程名字的话

        char* tname = new char[64];
        snprintf(tname, 64, "thread-%d", i + 1);

new了一块空间,这样开在堆上,是在循环中创建的,thread_run函数里return nullptr之前delete name,这样相当于每个线程都有自己的一块小空间,用完就去掉。在pthread_create函数调用前就会把所有参数都拷贝好,调用,传过去,然后会再次new,然后再去执行函数调用,每个线程用的空间都不一样。不过这个写法一般。

如果主线程后面没有死循环,是直接return 0,主线程和分线程都会持续执行,可以在return0之前sleep一段时间,使这10个分线程全部跑完以后return 0,那么所有线程都会退出。主线程一退出,就是进程退出,资源数据全部释放,分线程也全部退出;分线程创建时主线程需要等待,要不容易造成类似僵尸进程的现象。

2、pthread_join

在这里插入图片描述

    for(int i = 0; i < NUM; i++)
    {
    
    
        pthread_join(tids[i], nullptr);
    }

主线程要等新线程,新线程退出后主线程才能退,在这期间需要等待,join就用来等待。这个等待默认是阻塞的。第二个参数是一个输出型参数,会拿到新线程退出的结果。若成功则返回0。

3、线程终止

线程的终止有多种方式

1、线程函数执行完毕
2、exit退出。exit是进程退出,不是线程退出,所以哪个线程调用了exit,那么整个进程全部退出
3、pthread_exit线程退出接口

在这里插入图片描述

void* thread_run(void* args)
{
    
    
    char* name = (char*)args;
    while(true)
    {
    
    
        cout << "new thread running" << endl;
        sleep(1);
        break;
    }
    delete name;
    pthread_exit(nullptr);
    //return nullptr;
}

这个接口的参数和jion的第二个一样类型,用来接收新线程的结果。创建新线程后,我们需要知道给它的任务它完成得怎么样,需要返回结果。在pthread_ exit的括号里,我们可以传(void*)1,return也可以,对应上thread_run函数的类型以及pthread_ exit 函数的参数类型,主线程去接收它,接收的地方就在调用join接口之处,join的第二个参数是void**retval,应当这样理解void* *retval,所以就是变量类型和传入的变量的地址。

    delete name;
    pthread_exit(nullptr);
    //return nullptr;
}

int main()
{
    
    
    pthread_t tids[NUM];
    for(int i = 0; i < NUM; i++)
    {
    
    
        char* tname = new char[64];
        snprintf(tname, 64, "thread-%d", i + 1);
        pthread_create(tids + i, nullptr, thread_run, tname);
    }
    void* ret = nullptr;
    for(int i = 0; i < NUM; i++)
    {
    
    
        int n = pthread_join(tids[i], &ret);
        if(n != 0) cerr << "pthread_join error" << endl;
        cout << "thread quit: " << (uint64_t)ret << endl;
    }
    cout << "all thread quit..." << endl;

如果传回(void*)1,则ret里存着1,所以最后再强转一下变成int,打印出来。这里设置为1,最后会看到每个线程退出时都打印了一个1。

但这里会发现我们只是得到了一个返回来的数字而已,那么怎么知道线程有没有异常?其实不用担心这个问题,因为一旦异常,整个进程就结束了。

退出的时候既然类型是void,那么我们就可以传各种各样的类型,所以也就可以传类,可以把要看到的信息放在类里面。

#include <iostream>
#include <unistd.h>
#include <string>
#include <phread.h>
#include <ctime>
using namespace std;

#define NUM 10

class ThreadData
{
    
    
public:
    ThreadData(const string& name, int id, time_t createTime):_name(name), _id(id), _createTime((uint64_t)createTime)
    {
    
    }
    ~ThreadData()
    {
    
    }
public:
    string _name;
    int _id;
    uint64_t _createTime;
}


void* thread_run(void* args)
{
    
    
    //char* name = (char*)args;
    ThreadData* td = static_cast<ThreadData*>(args);//强转类型
    while(true)
    {
    
    
        cout << "thread is running, name" << td->_name << "create time: " << td->_createTime << "index: " <<td->_id << endl;
        sleep(4);
        break;
    }
    delete td ;
    pthread_exit(td);
    //return nullptr;
}

int main()
{
    
    
    pthread_t tids[NUM];
    for(int i = 0; i < NUM; i++)
    {
    
    
        //char* tname = new char[64];
        char tname[64];
        snprintf(tname, 64, "thread-%d", i + 1);
        ThreadData* td = new ThreadData(tname, i + 1, time(nullptr));
        pthread_create(tids + i, nullptr, thread_run, td);
    }
    void* ret = nullptr;
    for(int i = 0; i < NUM; i++)
    {
    
    
        int n = pthread_join(tids[i], &ret);
        if(n != 0) cerr << "pthread_join error" << endl;
        cout << "thread quit: " << (uint64_t)ret << endl;
    }
    cout << "all thread quit..." << endl;
    /*while(true)
    {
    
    
        cout << "main thread running, new thread id: " << endl;
        sleep(1);
    }*/
    return 0;
}

也可以把上一个进程退出时的数据等放回到类里面。在类里面放一个int变量,表示状态位,用enum来指定状态位,下面就这样写

    void* ret = nullptr;
    for(int i = 0; i < NUM; i++)
    {
    
    
        int n = pthread_join(tids[i], &ret);
        if(n != 0) cerr << "pthread_join error" << endl;
        //cout << "thread quit: " << (uint64_t)ret << endl;
        ThreadData* td = static_cast<ThreadData*>(ret);
        if(td->_status == OK)
        {
    
    
            cout << td->_name << endl;
        }
        delete td;
    }

取消正在终止的线程

在这里插入图片描述

void* threadRun(void* args)
{
    
    
    const char* name = (const char*)args;//static_cast<const char*>,传过来args参数的时候实际上是一个指针,指向字符串常量的起始地址
    int cnt = 5;
    while(cnt)
    {
    
    
        cout << name << "is running: " << cnt-- << "obtain self id: " << pthread_self() << endl;//查看本线程的id
        sleep(1);
    }
    pthread_exit((void*)1);
    //PTHREAD_CANCELED == ((void*)-1)
}

int main()
{
    
    
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRun, (void*)"thread 1");//省去一个变量,直接传一个字符串
    sleep(1);
    pthread_cancel(tid);
    void* ret = nullptr;
    pthread_join(tid, &ret);//join必须做,防止僵尸进程的类似问题
    cout << "new thread exit: " << (int64_t)ret << "quit thread: " << tid << endl;
    return 0;
}

如果一个进程被取消,那么返回的是PTHREAD_CANCELED,也就是((void*)-1)。

4、线程分离

主线程在等待新线程时可以不阻塞,而采用线程分离的方法来让主线程去做其它事,也可以主线程自己分离自己。一个线程如果被分离了就无法再被join,强行join就会报错。一个线程自带一个属性,就是可join,一旦分离,这个属性就无了。

在这里插入图片描述

#include <iostream>
#include <unistd.h>
#include <string>
#include <cstdio>
#include <cstring>
#include <phread.h>
#include <ctime>
using namespace std;

void* threadRun(void* args)
{
    
    
    string name = static_cast<const char*>(args);
    int cnt = 5;
    while(cnt)
    {
    
    
        cout << name << " : " << cnt-- << endl;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    
    
    pthread_t tid;
    pthread_create(&tid, nullptr, threadRun, (void*)"thread 1");
    pthread_detach(tid);
    int n = pthread_join(tid, nullptr);
    if(0 != n)
    {
    
    
        cerr << "error: " << n << " : " << strerror(n) << endl;
    }
    return 0;
}

会打印错误。如果不在主线程分离,而在新线程分离,开头写上pthread_detach(pthread_self()),最后发现会正常输出,但如果在main函数中的join之前sleep(1),就会打印错误并退出。

线程在创建的时候并没有决定执行的顺序。有的线程创建后,方法没有执行,而主线程此时会继续往下执行代码,那么就有可能让某个线程还没开始运行对应的方法就被join,也就是主线程就被挂起了,当然join之前得先判断是否能join,然后这个线程分离自己,但是主线程此时不知道这个事。

本篇代码,里面有锁的部分,暂且不用管。

结束。

猜你喜欢

转载自blog.csdn.net/kongqizyd146/article/details/130581998