【Linux】多线程---线程控制

进程在前面已经讲过了,所以这次我们来讨论一下多线程。

前言:线程的背景

  • 进程是Linux中资源及事物管理的基本单位,是系统进行资源分配和调度的一个独立单位。但是实现进程间通信需要借助操作系统中专门的通信机制,但是只这些机制将占用大量的空间资源,特别是少量数据传递时显得庞大而欠灵活。因此才出现了线程。

  • 线程和进程一样,具有创建,退出,取消,等待等基本操作,可以独立完成特定事物的处理。并且线程占用的资源更少。

1.线程的基本概念?

1.1什么是线程:

在一个程序里的一个执行路线叫做线程。一切进程至少有一个执行路线。

1.2线程与进程的对比

1.21用户空间资源对比

一个进程的创建包含着进程控制块(task_struct),进程地址空间(mm_struct),以及页表的创建。

线程是当前进程内的一个执行流,并且在进程地址空间里运行,这个进程申请的所有资源都被线程共享。
  • 但是如果用fork函数创建一个子进程,而线程用ptread_creat()函数创建一个新线程,一起对比一下所占资源情况!

总结:

  1. 每个进程在创建是额外申请了新的内存和空间以及存储代码,数据段,堆,栈空间。并且初始化为父进程的值,父子进程在创建后不能互访对方资源。

  1. 每个创建的新线程在用户阶段仅申请自己的栈空间,并且与同进程的其他线程共享其他地址空间,这使得同进程下的个线程共享数据很方便。但是带来的问题也是同步的,接下来我们会讲到。

1.22内核空间资源对比

1.前面的进程中我们说了,每一个进程在内核中都有自己的进程控制块PCB来识别当前进程所能够访问的系统资源。通过该进程的PCB可以访问到进程的所有资源。
目前在Linux下的进程统称为轻量级进程,甚至很多的书中都说LINUX并不区分进程与线程,如果我们站在内核的角度想:在创建线程时Linux内核仍然创建一个新的PCB来表示这个线程,而内核对他俩的认识都来自PCB,所以内核并不认为他俩有区别。

2.在Linux下每一个进程的PCB的mm_struct都用来描述地址空间。而父子进程间的地址空间是分开的;而同一个进程下的线程共享这个地址空间,所以才在 用户的角度说两者是有区别的。但是从 调度的角度来看,操作系统是基于线程调度的,及内核并不认为他俩有区别。

3.一个进程如果不创建新的线程,那他就是只有一个线程的进程;如果创建了额外的线程,原来的进程也称为 主线程
总结一下:
4.进程在使用时占用了大量的内存,特别是进程间通信,这使得进程不够灵活,而且耗费资源,而线程占用资源少,使用灵活,且同进程下的线程间数据交互不需要经过OS,所以很多应用程序都使用了大量的进程,但是线程不能脱离进程而存在。
线程引进能做啥事?
如果说进程中有10个函数,但是只有一个线程,那这10个函数一定是按照顺序一次进行,但是如果有多个线程被创建,那这几个线程就可以分别实现这几个函数。
  • 单线程执行流被调度:

  • 多个执行流被调度:

linxu下没有真正的多线程
1.操作系统中存在大量的进程,一个进程内又存在一个或多个线程,因此线程的数量一定比进程的数量多,当线程的数量足够多的时候,很明显线程的执行粒度要比进程更细。

2.如果一款操作系统要支持真的线程,那么就需要对这些线程进行管理。比如说创建线程、终止线程、调度线程、切换线程、给线程分配资源、释放资源以及回收资源等等,这个和进程的绝大多数功能重复,所以委员会决定在内核层面没必要去区分他俩,都是用test_struct去表示。

3.因此,如果要支持真的线程一定会提高设计操作系统的复杂程度。在Linux看来,描述线程的控制块和描述进程的控制块是类似的,因此Linux并没有重新为线程设计数据结构,而是直接复用了进程控制块,所以我们说Linux中的所有执行流都叫做轻量级进程。

既然linxu中没有真正意义上的线程,所以就没有真正意义上的线程调用了。但是Linux可以提供创建轻量级进程的接口,也就是创建进程,共享空间,其中最典型的代表就是vfork函数。

提到vfork函数就不得不提到他的老大哥fork函数,这个vfork()函数其实就是fork的阉割版,fork创建的子进程是完全1:1模仿的父进程,但是操作者创建的进程如果是只想实现一些小功能函数,就不需要完全复制父进程。
fork():父进程的一个副本(代码+数据)
vfork():共享父进程的代码与数据

vfork()原型:

pid_t vfork(void);

vfork函数的返回值与fork函数的返回值相同:

  • 给父进程返回子进程的PID。

  • 给子进程返回0。

例如在下面的代码中,父进程使用vfork函数创建子进程,子进程将全局变量g_val由100改为了200,父进程休眠3秒后再读取到全局变量g_val的值。

可以看到,父进程读取到g_val的值是子进程修改后的值,也就证明了vfork创建的子进程与其父进程是共享地址空间的。

1.3从新理解啥是进程

  • 在用户视角:

内核数据结构+该进程对应的代码和数据
  • 内核视角:

承担系统资源分配的基本实体

刚开始创建一个进程时,该进程就会向操作系统要资源(内核区+栈+堆...),他是以进程的身份向系统要资源。当资源要完了,又创建新的线程时,线程就不在伸手向系统要了,而是向你这个进程要了。换句话说,创建新进程时,向操作系统要资源的不是线程,而是以进程为单位要的。既然是进程要的,那系统分配资源时不就是以进程为基本单位进行分配的吗?

怎样看我们写的代码呢?

原来我们写的代码都是一个task_struct,但是现在有多个task_struct,如果说以前的叫做单进程多线程的话,那现在的就叫做多进程多线程了,也就是说原来的是现在多个task_struct的一个子集。

也就是说一个进程就是一个地址空间,而一个线程就是一个task_struct。

  • CPU视角:

其实CPU不关心到底系统是咋区分进程和线程的,他只看task_struct,只要运行队列中有task_struct,就直接执行其中的代码和数据。CPU不关心到底代码和数据是和谁共享的,只要能执行就行。

1.4进程和线程之间的关系

  1. 线程的创建

2.1线程创建函数

一般我们都是用pthread_create()来创建一个线程的

man pthread_create   //查看其函数声明
  • 第一个参数:线程ID,没错进程有进程ID,线程有线程ID

  • 第二个参数:设置线程属性,没毛病知道我们学完线程这个默认也没关系,不用改。

  • 第三个参数:设置线程运行的代码起始地址是个函数指针。因为线程是进程一部分,所以用函数指针来接受进程的地址

  • 第四个参数:运行函数的参数地址。

2.2创建线程

先创建一个Makefile文件:

mythread:mythread.cc
    g++ -o $@ $^ -lpthread
.PHONY:clean
clean:
    rm -f mythread

那个lpthread是引入线程库,否则要是新创建一个线程的话就会报错

然后在创建一个thread.cc来存放代码:

#include <iostream>
#include <unistd.h> //这个getpid函数头文件
#include <pthread.h>
#include <cstdio>
#include <string.h>

using namespace std; // 这是C++中的命名空间

void *threadRun(void *args)
{
    const string name = (char *)args;
    while (true)
    {

        cout << name << ", pid:" << getpid() << "\n"
             << endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid[5]; // 一次创建5个线程
    char name[64];    // 这是线程名,但是可能有点小问题
    for (int i = 0; i < 5; i++)
    {
        snprintf(name, sizeof name, "%s-%d", "thread", i); // 给线程重新命名
        pthread_create(tid + i, NULL, threadRun, (void *)name);
        sleep(1);
    }
    while (true)
    {
        cout << "main thread, pid:" << getpid() << endl;
        sleep(3); // 避免和上面的混淆
    }
}
ldd mythread

确认一下有没有用到这里的库

运行一下mythread--->./mythread

接下来我们创建一个分屏:然后用ps axj查看命令来查看:

ps axj | head -1 && ps axj |grep mythread

但是这里却只有一个进程

不是说系统可以创建轻量级进程吗?在哪呢?

输入以下命令:

ps -aL
ps -aL | head -1 && ps -aL |grep mythread

看见PID后面那个LWP没那个就是轻量级进程对应的PID,19,20,26...

而第一个线程的PID和LWP是一样的,所以他叫做主线程。

所以操作系统调度线程时看的是LWP,因为PID和线程是一对多的关系。

我们输入一下kill -9 PID

当我们结束一个进程,那他其中的所有线程就都结束了。

代码区的共享

我再新写一个函数

void show(const string &name)
{
    cout << name << ", pid:" << getpid() << "\n" << endl;
}

void *threadRun(void *args)
{
    const string name = (char *)args;
    while (true)
    {

        show(name);     
        sleep(1);
    }
}

int main()
{
    pthread_t tid[5]; // 一次创建5个线程
    char name[64];    // 这是线程名,但是可能有点小问题
    for (int i = 0; i < 5; i++)
    {
        snprintf(name, sizeof name, "%s-%d", "thread", i); // 给线程重新命名
        pthread_create(tid + i, NULL, threadRun, (void *)name);
        sleep(1);
    }
    while (true)
    {
        cout << "main thread, pid:" << getpid() << endl;
        sleep(3); // 避免和上面的混淆
    }
}

这个函数能被所有的线程进行访问,再重新make一下,再编译也能跑。

  • 当然全局变量也可以被共享

2.3线程的缺点

说了这么多,我们就来聊一聊写线程时遇到的缺点:

  • 1.性能损失

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

  • 2.健壮性降低

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

  • 3.缺乏访问控制

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

  • 4.编程难度提高

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

2.4线程异常

这个后面会做示范,这里就提一下:

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

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

这里我们再重新创建一个新线程跑一跑试试

makefile文件和上面一样,为了方便继续用mythread.cc

#include <iostream>
#include <unistd.h> //这个getpid函数头文件
#include <pthread.h>
#include <cstdio>
#include <string.h>

using namespace std;

void *threadRoutine(void *args)
{ 
    while(true)
    {
        cout << "新线程: " << (char *)args << "running..." << endl;
        sleep(1);
    }
}
int main()
{
    pthread_t tid; // 创建线程
    pthread_create(&tid, NULL, threadRoutine, (void *)"thread 1");
    while (true)
    {
        cout << "main线程: "
             << " running ..." << endl;
        sleep(1);
    }
    return 0;
}

这两个新线程都可以跑

上面不是说如果一个线程崩溃其他线程也不能跑了,我们试一下吧。

我们把代码改一下:

这里的\=0代表 硬件异常,CPU中的状态标记为被置为0,这里我们来模拟线程出错。

重新make一下,报错不管,然后编译。

出现这个错误,然后在输入kill -l

又出现这个信号

这里就不存在我们创建的mythread线程了。

  1. 所以线程谁先运行与调度器有关。

  1. 如果一个线程出现异常,都可能导致整个进程退出。

  1. 线程在创建和执行的时候也是需要等待的。如果主线程不等待,就会导致

3.线程等待

3.1线程等待函数pthread_join()

man pthread_join
  • thread:被等待线程的ID。

  • retval:线程退出时的退出码信息。

void *threadRoutine(void *args)
{
    int i = 0;
    while (true)
    {
        cout << "新线程: " << (char *)args << "running..." << endl;
        sleep(1);
        if (i++ == 10)
        {
            break;
        }
    }
    cout << " new thread quit..." << endl;
}
int main()
{
    pthread_t tid; // 创建线程
    pthread_create(&tid, NULL, threadRoutine, (void *)"thread 1");

    pthread_join(tid, NULL);   //进程等待函数
    cout << "main thread wait down ...." << endl;
    while (true)
    {
        cout << "main线程: "
             << " running ..." << endl;
        sleep(1);
    }
    return 0;
}

如果发生进程等待就是先打印新线程,后打印main线程

一点毛病没有。

总结一下:

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

  1. 如果thread线程被别的线程调用pthread_cancel异常终止掉,retval所指向的单元里存放的是常数PTHREAD_CANCELED。

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

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

3.2pthread_join第二个参数

我们写的功能函数的返回值是void*, 这个返回类型可以自己设置。

就比如我们让threadRoutine函数返回10,但是必须要强制类型转换成void*, 说白了就是把10当做一个指针数据,也就是说有一个地址,这个地址是10。

返回值但是返回给谁呢?

当然是返回给主线程了,主线程创建分线程就是办事的,所以必须要知道事办的咋样。但是主线程咋接收?所以此时就用到pthread_join()的第二个参数了。

指针就是一个地址,地址说白了就是一个数据,我们就把他看做自变常量。

void* 10看做一个数据。下面的ret看做空间。取指针的地址,所以第二个参数就是二级指针。

void *threadRoutine(void *args)
{
    int i = 0;
    while (true)
    {
        cout << "新线程: " << (char *)args << "running..." << endl;
        sleep(1);
      
        if (i++ == 3)
        {
            break;
        }
    }
    cout << " new thread quit..." << endl;
    return (void*) 10;
}
int main()
{
    pthread_t tid; // 创建线程
    pthread_create(&tid, NULL, threadRoutine, (void *)"thread 1");

    void* ret = NULL;
    pthread_join(tid, &ret); // 进程等待函数,默认阻塞等待新线程退出

    cout << "main thread wait down .... new thread init:" <<(long long)ret<<"\n"<< endl;
    
    return 0;
}

新线程退出以后,主线程就活动新线程的返回值了。

我们在来个新玩法:

void *threadRoutine(void *args)
{
    int i = 0;
    int *date = new int[10];
    while (true)
    {
        cout << "新线程: " << (char *)args << "running..." << endl;
        sleep(1);
      date[i]=i;
        if (i++ == 10)
        {
            break;
        }
    }
    cout << " new thread quit..." << endl;
   // return (void*) 10;
   return (void*)date;
}
int main()
{
    pthread_t tid; // 创建线程
    pthread_create(&tid, NULL, threadRoutine, (void *)"thread 1");

   int *ret = NULL;
    pthread_join(tid, (void**)&ret); // 进程等待函数,默认阻塞等待新线程退出

    cout << "main thread wait down .... new thread init:"<<"\n"<< endl;
    for(int i=0;i<10;i++)
    {
        cout<<ret[i]<<endl;
    }
    return 0;
}

这就显出来了

4.线程终止

进程可以终止,线程可以终止吗?

他自动停止了。

在线程中绝对不能调用exit,他是终止线程的。

4.1pthread_exit()

线程有他自己的结束函数:pthread_exit()

当然return也可以线程退出,但是这里就不过多的介绍了。

4.2pthread_cancel

线程取消函数也可以退出线程

man pthread_cancel
//q键退出

取消那个线程就把它的id传入就行了。

主线程可以取消新线程,取消成功的线程的退出码一般是-1,我们让新线程进入死循环,并且把新线程id传给取消函数。

using namespace std;

void *threadRoutine(void *args)
{
    int i = 0;
    int *date = new int[10];
    while (true)
    {
        cout << "新线程: " << (char *)args << "running..." << endl;
        sleep(1);
      date[i]=i;
        if (i++ == 5)
        {
            break;
        }
    }
    cout << " new thread quit..." << endl;
}
int main()
{
    pthread_t tid; // 创建线程
    pthread_create(&tid, NULL, threadRoutine, (void *)"thread 1");
    
    int count=0;
    while(true)
    {
    
        cout<<"main()线程:"<<"running..."<<endl;
        sleep(1);
        count++;
        if(count>=5)break;
    }
    pthread_cancel(tid);
    cout<<"pthread_cancel:"<<tid<<endl;
  int *ret = NULL;
    pthread_join(tid, (void**)&ret); // 进程等待函数,默认阻塞等待新线程退出
    cout << "main thread wait down .... new thread init:"<<(long long)ret<<"\n"<< endl;
    sleep(3);  //让主线程多活几秒
return 0;
}

接下来执行命令来监视一波:

while :; do ps -aL  | head -1 && ps -aL | grep mythread ; sleep 1 ; done 

既然主线程可以取消新线程,那反过来其实也可以,但是有啥实际意义呢,主线程取消,谁来帮忙管理其他新线程呢?而且内容比较凌乱,这里就不过多介绍了。

4.3线程id

刚才我们看到了一大串的数字,他们就被称作线程id,但是有些老铁认为这个线程id和线程的lwp其实是一个东西,但是他俩真的一样吗?

其实他俩没啥关系。id后面那一串数字是它的64位。而且id代表的是一个地址,所以它才是这一串数字。其实当我们调用pthread函数来实现一些对线程的操作时,我们调用的是pthread库而不是调用linux系统。当使用pthread库时,这个库就被加载到内存上面,而线程通过一系列操作最终被页表映射到内存上面。所以id其实就代表线程被映射到内存上的地址。

另外,主线程被系统存在cpu的栈里,而新线程则被存放在共享区新开辟的栈里。当时单执行流就调用cpu里主线程栈来操作。

获取自身线程id的函数:pthread_self

5.线程分离

  • 默认情况下,新创建的线程时joinable的,线程退出后,pthread_join操作,否则无法释放资源,从而造成系统泄漏。

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

分离函数pthread_detach

int pthread_detach(pthread_t thread);

这里就不在细讲了,各位有兴趣去搜一下资料吧,饿死了!!!

  • 8
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 18
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 18
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值