[Linux]:线程(一)

img

✨✨ 欢迎大家来到贝蒂大讲堂✨✨

🎈🎈养成好习惯,先赞后看哦~🎈🎈

所属专栏:Linux学习
贝蒂的主页:Betty’s blog

1. 初识线程

1.1 线程的概念

在操作系统中,进程与线程一直是我们非常关注的话题,它们共同构建了程序的执行环境,前面我们已经介绍了进程,今天我们要了解的就是线程,在此之前,我们就得先谈谈进程与线程的区别:

  • 进程:进程是程序的一次执行实例。它是操作系统资源分配的基本单位。每个进程都有自己的内存空间、进程地址空间,文件描述符表、全局变量等系统资源。
  • 线程:线程是进程中的一个执行单元。一个进程可以包含多个线程,它们共享进程的资源(如内存、进程地址空间,文件描述符表等),但每个线程有自己独立的寄存器(存储上下文)、栈(保存临时数据)和程序计数器(记录指令执行位置),errno(错误码) 等。

操作系统为了方便多个进程,有了进程控制块 PCB,同样为了管理我们的线程也应该创建我们的TCB 结构(thread ctrl block),但是值得一提的是:在我们 Linux操作系统中,为了提高代码的可复用性,降低我们的维护成本,采用进程的内核数据结构也就是 task_struct来模拟的线程,所以我们常说 Linux中没有真正意义上的线程。

并且 CPU 中只有执行流的概念,所以原则上来说 CPU并不会区分进程与线程,但是 Linux操作系统需要区分线程与进程,所以我们可以称线程为轻量化进程。

画板

其中上图我们用虚线框住的就是我们的进程,而一个 task_struct代表的就是一个线程。并且进程与线程的关系如下图:

画板

1.2 线程的优缺点

1.2.1 线程的优点
  1. 创建一个新线程的代价要比创建一个新进程小得多。
  2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
  3. 线程占用的资源要比进程少很多。
  4. 能充分利用多处理器的可并行数量。
  5. 在等待慢速 IO 操作结束的同时,程序可执行其他的计算任务。
  6. 计算密集型应用,为了能在多处理器系统上运行。将计算分解到多个线程中实现。
  7. IO 密集型应用,为了提高性能,将 IO 操作重叠,线程可以同时等待不同的 IO 操作。

其中计算密集型指:执行流的大部分任务,主要以计算为主。比如加密解密、大数据查找。IO 密集型指:执行流的大部分任务,主要以 IO 为主。比如刷磁盘、访问数据库、访问网络等。

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

1.3 线程异常

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

1.4 线程用途

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

2. 线程控制

Linux 的内核中只有轻量级进程的概念,并无明确的线程概念,因此 Linux 操作系统不会直接为用户提供线程的系统调用,仅会提供轻量级进程的系统调用。然而这些系统调用使用成本较高,所以在应用层又为用户开发出了 pthread 线程库。几乎所有的 Linux 平台都默认自带这个库。在 Linux 中编写多线程代码需要使用第三方的 pthread 库。

因为 pthread线程库是第三方为用户提供的动态库,也叫原生线程库,所以编译时需要加上 -lpthread选项。

2.1. 线程创建

  1. 函数原型:int pthread_create(pthread_t thread,const pthread_attr_t attr,void(start_routine)(void),voidarg);
  2. 参数:
  • thread:输出型参数,返回用户层线程 ID
  • attr:设置线程的属性,为 NULL 表示使用默认属性。
  • start_routine:是个函数地址,线程启动后要执行的函数。
  • arg:传给启动线程的参数。
  1. 返回值:创建成功返回0,创建失败返回对应的错误码。

比如下面这段代码,我们让其创建出一个线程,并观察其 PID

#include<iostream>
using namespace std;
#include<unistd.h>
#include<pthread.h>
void* threadRoutine(void*args)
{
    while(true)
    {
        cout<<"new thread,pid: "<<getpid()<<endl;
        sleep(2);
    }
    return nullptr;
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,threadRoutine,nullptr);
    while(true)
    {
        cout<<"main thread,pid: "<<getpid()<<endl;
        sleep(1);
    }
    return 0;
}

我们观察到主线程与新创建的线程的 pid相同,这样证明它们是同属于一个进程的。

并且我们也可以通过指令ps -aL查看当前操作系统中的所有轻量级线程。

其中LWP就是指一个轻量级进程的 ID,如果一个线程的PID == LWP ,我们就称该线程为主线程。

然后我们可以在了解一个接口 pthread_self获取当前用户层线程的 ID(即 pthread_create 第一个参数),其原型如下:

pthread_t pthread_self(void);//其中 pthread_t一般是一个无符号长整型,具体取决于实现

比如我们可以创建五个线程,分别打印其进程与线程 ID观察。

#include <iostream>
using namespace std;
#include <unistd.h>
#include <cstdio>
#include <pthread.h>
void *Routine(void *args)
{
    char *buffer = (char *)args;
    cout << "I am " << buffer << ",pid:" << getpid() << ", tid:" << pthread_self() << endl;
    sleep(1);
    return nullptr;
}
int main()
{
    pthread_t tid[5];
    for (int i = 0; i < 5; i++)
    {
        char buffer[64] = {'\0'};
        snprintf(buffer, sizeof(buffer), "thread %d", i);
        pthread_create(&tid[i], nullptr, Routine, buffer);
        sleep(1);
    }
    while (true)
    {
        cout << "I am main thread,pid:" << getpid() << ", tid:" << pthread_self() << endl;
        sleep(1);
    }
    return 0;
}

值得注意的是: pthread_self 函数获得的线程 ID 与内核的 LWP 值是不相等的,pthread_self 函数获得的是用户级原生线程库的线程 ID,而 LWP 是内核的轻量级进程 ID,它们之间是一对一的关系。

要想搞清楚用户级原生线程库的线程 ID与内核 LWP的区别,我们首先得明白所使用的原生线程库本质其实就是一个动态库,在程序运行时,其会被加载到内存共享区中。

画板

上面我们就提到每一个线程都有自己独立的栈结构,其中主线程采用的栈是进程地址空间中的原生栈,而其余线程采用的栈就是在共享区中开辟的。除此之外,每个线程都有自己的 struct pthread结构,当中包含了对应线程的各种属性;每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。

每一个新线程在共享区都存在一块对其进行描述的区域,所以要找到一个用户级线程,只需找到该线程内存块的起始地址,这样就能获取到该线程的各种信息。所以用户层线程 <font style="color:rgb(28, 31, 35);">ID</font>本质就是一个指向线程起始位置的虚拟地址。

画板

每个线程在创建后都需拥有独立的栈结构。原因在于每个线程都具备自身的调用链,而执行流的本质正是调用链。此栈空间能够保存一个执行流在运行期间所产生的临时变量,并且在函数调用时进行入栈操作。而 LWP则只是操作系统在内核唯一标识轻量级进程的编号。

2.2 线程等待

其实一个线程被创建出来也是需要被等待的,如果不等待,也会发生类似进程的"僵尸"问题,即内存泄漏。而线程等待我们需要使用的接口是 pthread_join

  1. 函数原型:int pthread_join(pthread_t thread,void**retval);
  2. 参数:
  • thread:要等待的线程 ID。
  • retval:输出型参数,获取线程函数的返回值,如果不关心可传 nullptr
  1. 返回值:等待成功返回 0;等待失败,返回对应的错误码。

比如下面我们创建一个线程,让其退出后返回一个值,让线程等待获取这个值。

#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int g_val = 100;
void *threadRoutine(void *args)
{
    const char *name = (const char *)args;
    int cnt = 5;
    while (true)
    {
        printf("%s, pid: %d, g_val: %d, &g_val: %p\n", name, getpid(), g_val, &g_val);
        sleep(1);
        cnt--;
        if (cnt == 0)
            break;
    }

    return (void *)100;
}
int main()
{
    pthread_t pid;
    pthread_create(&pid, nullptr, threadRoutine, (void *)"Thread 1");
    void *ret;
    pthread_join(pid, &ret);
    cout << "main thread quit..., Thread 1 return val: " << (long long int)ret << endl;
    return 0;
}

其中主线程等待时,默认是阻塞等待。当然在线程运行时也可能发生异常退出,比如除零错误,这时整个进程都会异常退出,此时我们就可以接受 pthread_join的返回值,判断具体是什么异常。

2.3 线程终止

我们除了在一个线程函数中使用 return终止线程外,还可以通过接口 pthread_exit终止线程,其具体用法如下:

  1. 函数原型:void pthread_exit(void*retval);
  2. 参数:retval :线程函数的返回值。

比如下面我们创建一个线程,让其通过 pthread_exit退出后返回一个值,再让线程等待获取这个值。

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

using namespace std;

int g_val = 100;

void *threadRoutine(void *args)
{
    const char *name = (const char*)args;
    int cnt = 5;
    while (true)
    {
        printf("%s, pid: %d, g_val: %d, &g_val: %p\n", name, getpid(), g_val, &g_val);
        sleep(1);
        cnt--;
        if(cnt == 0) break;
    }

    pthread_exit((void *)200);
}

int main()
{
    pthread_t pid;
    pthread_create(&pid, nullptr, threadRoutine, (void *)"Thread 1");
    void *ret;
    pthread_join(pid, &ret);
    cout << "main thread quit..., Thread 1 return val: " << (long long int)ret << endl;
    return 0;
}

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

2.4 线程取消

我们也可以通过 pthread_cancel接口取消一个已经存在的线程,其用法如下:

  1. 函数原型:int pthread_cancel(pthread_t thread);
  2. 参数:thread:要取消的线程 ID。
  3. 返回值:取消成功返回 0;取消失败,返回对应的错误码。

比如下面我们创建一个线程,让其通过 pthread_cancel取消,再让线程等待获取其返回值。

#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int g_val = 100;
void *threadRoutine(void *args)
{
    const char *name = (const char*)args;
    int cnt = 5;
    while (true)
    {
        printf("%s, pid: %d, g_val: %d, &g_val: %p\n", name, getpid(), g_val, &g_val);
        sleep(1);
        cnt--;
        if(cnt == 0) break;
    }

    pthread_exit((void *)200);
}
int main()
{
    pthread_t pid;
    pthread_create(&pid, nullptr, threadRoutine, (void *)"Thread 1");
    sleep(1);
    pthread_cancel(pid);
    void *ret;
    pthread_join(pid, &ret);
    cout << "main thread quit..., Thread 1 return val: " << (long long int)ret << endl;
    return 0;
}

如果一个线程被取消,它会返回一个名为 PTHREAD_CANCELED 的宏,其值为-1。

其实我们也能够通过新线程来取消我们的主线程,主线程会停止运行,但其他线程并不会收到任何影响。但这种做法并不符合我们的一般逻辑,所以并不推荐。

2.5 线程分离

在默认情况下,新创建的线程是 joinable 的,线程退出后,需要我们对其进行 pthread_join 操作,否则无法释放资源,从而造成资源泄露。但是如果主线程不关心子线程的返回值,join 其实也成是一种负担,这个时候,我们可以使用 pthread_detach接口,让当线程退出时,自动释放线程资源。其具体用法如下:

  1. 函数原型:int pthread_detach(pthread_t thread);
  2. 参数:thread:要分离的线程 ID。
  3. 返回值:分离成功返回 0;分离失败,返回对应的错误码。

比如下面我们创建五个新线程后让这五个新线程分离,此后主线程就不需要在对这五个新线程进行回收了。同时因为主线程并不需要等待其他线程,也能继续执行后续代码。

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
using namespace std;
void *Routine(void *arg)
{
    pthread_detach(pthread_self());
    char *msg = (char *)arg;
    int count = 0;
    while (count < 5)
    {
        printf("I am %s...pid: %d,tid: %lu\n", msg, getpid(),pthread_self());
        sleep(1);
        count++;
    }
    pthread_exit((void *)10);
}
int main()
{
    pthread_t tid[5];
    for (int i = 0; i < 5; i++)
    {
        char buffer[64] = {'\0'};
        snprintf(buffer, sizeof(buffer),"thread %d", i);
        pthread_create(&tid[i], nullptr, Routine, buffer);
        sleep(1);
    }
    while (true)
    {
        printf("I am main thread...pid: %d,tid: %lu\n", getpid(),pthread_self());
        sleep(1);
    }
    return 0;
}

2.6 线程的局部存储

我们知道普通的全局变量是被所有线程所共享的,如果想让该全局变量被每个线程各自私有一份,可以在定义全局变量的前面加上 __thread ,这并不是语言给我们提供的,而是编译器给我们提供。并且 __thread 只能用来修饰内置类型,不能用来修饰自定义类型。

比如我们创建五个线程,并用 __thread定义一个全局变量 val,在各个新线程中打印其值域地址。

#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
using namespace std;
__thread int val = 100;
void *Routine(void *arg)
{
    pthread_detach(pthread_self());
    char *msg = (char *)arg;
    printf("I am %s...val:%d,&val:%p\n", msg, val, &val);
    sleep(1);
    while(true);
}
int main()
{
    pthread_t tid[5];
    for (int i = 0; i < 5; i++)
    {
        char buffer[64] = {'\0'};
        snprintf(buffer, sizeof(buffer), "thread %d", i);
        pthread_create(&tid[i], nullptr, Routine, buffer);
        sleep(1);
    }
    sleep(3);
    return 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Betty’s Sweet

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值