【Linux】线程控制 POSIX 线程库详解与 C++ 线程库封装实践

文章目录


一、线程控制

POSIX线程库

1、与线程有关的函数构成了⼀个完整的系列,绝⼤多数库函数的名字都是以"pthread_"打头的。

2、要使⽤这些库函数,要通过引⼊头⽂件 <pthread.h>。

3、链接这些线程函数库时要使⽤编译器命令的"-l pthread"选项,指明要引入哪个库,不用加-L因为pthread库本身就在系统默认路径下了。

创建线程

参数:

第一个参数是输出型参数,用来带出创建出的新线程的tid。但是它并不是LWP,而是LWP的上层封装,因为LWP是内核级数据,不需要通过库函数暴露给用户,我们只用知道它也是一种标识线程的具有唯一性的id。

pthread_t是一个无符号长整形,定义如下:

cpp 复制代码
typedef unsigned long int pthread_t;

第二个参数是输入项参数,用来设置线程属性,大部分情况都不需要设置,传nullptr即可。

第三个参数是输入项参数,回调函数,未来交给新线程执行。(本质就是一套虚拟地址,主线程访问main的虚拟地址,新线程访问回调函数的虚拟地址)

第四个参数是输入项参数,未来要作为参数传给回调函数。
返回值: 成功返回0,失败返回错误码。
示例代码:

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>

void *thread_routine(void *arg)
{
    std::string name = static_cast<const char*>(arg);
    while(true)
    {
        std::cout << "我是新线程..., 名字是:" << name << std::endl;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");
    (void) n; //避免警告

    while(true)
    {
        std::cout << "我是主线程..." << std::endl;
        sleep(1);
    }
    return 0;
}

补充:static_cast 是 C++ 提供的静态类型转换运算符,用于在编译期进行类型转换,语法格式为:static_cast<目标类型>(源对象)。
同一进程中的不同线程全局变量(gval)和其他函数(func)共享,堆空间原则上共享,为什么堆空间是原则上共享呢,如果一个堆空间的起始虚拟地址是全局的,这个堆空间就是所有线程共享的,如果一个线程单独申请一个自己的堆空间,并且该堆空间的起始虚拟地址只有该线程自己知道,这时该堆空间就是独属于该线程的。

示例代码如下:

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>

volatile int gval = 0;

std::string func()
{
    return "我是另一个函数\n";
}

void *thread_routine(void *arg)
{
    std::string name = static_cast<const char*>(arg);
    while(true)
    {
        printf("我是新线程..., gval:%d, &gval:%p, %s", gval, &gval, func().c_str());
        gval++;
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");
    (void) n; //避免警告

    while(true)
    {
        printf("我是主线程..., gval:%d, &gval:%p, %s", gval, &gval, func().c_str());
        sleep(1);
    }
    return 0;
}

运行结果:

这里小编说明几点:

1、除了我们在之前信号章节介绍的主函数执行流会和信号处理函数执行流发生函数重入问题(一个函数被多个执行流进入称作函数重入),这里不同线程之间的执行流之间也会可能发生。多进程之间不会发生重入问题,因为会发生写时拷贝。

2、创建新线程后主线程和新线程哪个先运行是不确定的,所以我们代码中为了让主线程访问新线程的数据,先让主线程sleep两秒。

获取线程tid

获取调用该函数的线程id:

线程终止

如果需要只终⽌某个线程⽽不终⽌整个进程,可以有三种⽅法,注意不能直接调用exit,它会使进程整体退出。

  1. 从线程函数return。这种⽅法对主线程不适⽤,从main函数return相当于调⽤exit。
cpp 复制代码
void *thread_routine(void *arg)
{
    std::string name = static_cast<const char *>(arg);
    while (true)
    {
        printf("我是新线程..., gval:%d, &gval:%p, %s", gval, &gval, func().c_str());
        sleep(1);
        break;
    }
    // 如果新线程完成了自己的入口函数,return nullptr表示该线程退出
    return nullptr;
}
  1. 线程可以调⽤pthread_ exit终止自己。
cpp 复制代码
void *thread_routine(void *arg)
{
    std::string name = static_cast<const char *>(arg);
    while (true)
    {
        printf("我是新线程..., gval:%d, &gval:%p, %s", gval, &gval, func().c_str());
        sleep(1);
        break;
    }
    // 如果新线程完成了自己的入口函数,return nullptr表示该线程退出
    // return nullptr;
    pthread_exit(nullptr);
}
  1. ⼀个线程可以调⽤pthread_ cancel终⽌同⼀进程中的另⼀个线程,也可以自己终止自己:

线程退出的最佳实践:

新线程自己退出:法1法2。

主线程让其他线程退出:法3。

线程等待

在多线程代码中,当main函数退出后表示主线程退出,同时也表示当前进程结束,当进程结束后当前进程在内核中除了task_struct外所有数据都要被回收,包括虚拟地址空间,所以当前进程中的线程没有了生存的空间,自然也要跟着主线程退出。

所以在多线程代码中,我们希望主线程最后退出,所以线程也需要向=像进程一样进行等待,否则就会出现类似僵尸进程一样的问题,之所以说"类似"是因为linux中并不存在僵尸线程。

线程等待函数:

它是阻塞式等待,这样能确保主线程最后退出。

自动解决新线程内存泄漏以及僵尸问题。
参数:

第一个参数是输入型参数,传入要等待的目标线程。

第二个参数是输出型参数,用来获取新线程退出的退出信息,(用二级指针void**将线程执行函数的返回值void*带出来)
返回值: 成功返回0,失败返回错误码。
下面是多个线程进行线程等待的示例:

cpp 复制代码
#include <string>
#include <vector>
#include <unistd.h>
#include <pthread.h>

volatile int gval = 0;

std::string func()
{
    return "我是另一个函数\n";
}

void *thread_routine(void *arg)
{
    std::string name = static_cast<const char*>(arg);
    int cnt = 5;
    while(cnt--)
    {
        printf("我是新线程..., gval:%d, &gval:%p, %s", gval, &gval, func().c_str());
        gval++;
        sleep(1);
    }
    //如果新线程完成了自己的入口函数,return nullptr表示该线程退出
    return nullptr; 
}

int gnum = 10;

int main()
{
    std::vector<pthread_t> tids;

    //一次性创建多个线程
    for(int i = 0; i < gnum; i++)
    {
        pthread_t tid;
        int n = pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");
        (void) n;

        tids.push_back(tid);
    }

    //打印创建的新线程
    for(auto &tid : tids)
    {
        printf("main create a new thread,new thread id:%lx\n", tid);
    }

    //主线程等待所有新线程
    for(auto &tid : tids)
    {
        pthread_join(tid, nullptr);
        printf("thread end, 退出的线程是:%lx\n", tid);
    }
    printf("main end\n");

    return 0;
}

线程传参和返回值

1、 先在编码层面看看线程如何将数值返回:

cpp 复制代码
void *thread_routine(void *arg)
{
    std::string name = static_cast<const char *>(arg);
    while (true)
    {
        printf("我是新线程..., gval:%d, &gval:%p, %s", gval, &gval, func().c_str());
        sleep(1);
        break;
    }

    // 下面两种方法均可
    // return (void*)20;
    pthread_exit((void*)10);
}

int gnum = 10;

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");
    (void)n;

    void* id;
    int m = pthread_join(tid, &id);
    (void)m;

    printf("main thread end,end id:%lld\n", (long long)id);

    return 0;
}

现在线程等待pthread_join接口已经可以获取到线程的退出码了,也就是主线程现在可以得知新线程执行完后结果对还是不对。

现在有一个问题,我们在介绍进程时不是说退出信息=退出码+信号吗?难道线程退出我们不用考虑线程是否出现异常了吗?答案是肯定的,因为根本每机会考虑,一但心线程出现异常,会导致整个进程整体退出,包括主线程,所以主线程根本就没机会调用pthread_join接受异常信号信息。

所以异常就应该让进程考虑,当子进程中某个线程出现异常时,应该让父进程接受异常信号信息。
2、下面我们来介绍一下新线程把退出信息交给主线程、主线程通过pthread_join接受新线程返回信息这一过程的原理。

首先我们回忆一下之前我们自己封装的一个简易C标准库,其中my_fopen返回的FILE对象用户本身并没有申请,所以FILE 对象是在库里malloc的,所以这里可以证明库是可以维护结构体对象的。

而在linux中本身是没有线程概念的,但是用户可以在linux中使用线程,这是因为线程概念是由pthread库提供的。既然有了对应的概念,那么就一定存在具体实现,所以在pthread库中就会存在描述并组织线程的结构体,结构体内部会有LWP字段,还会有一个存放线程退出码的void*类型变量ret。我们前面介绍的pthread_create带出的pthread_t

pid就是该结构体变量在库中的起始地址,新线程的返回值会先暂存线程结构体中的ret变量中,等待主线程通过pthread_join用二级指针将返回值带出。

所以这里我们也会明白一个OS学科中的概念:用户级线程和内核级线程,linux就是用户级线程,因为linux实现是在用户级的pthread库中,windows是内核级线程,因为windows把线程实现在内核内部。

用户层传参和返回值

我们学习使用线程库这些接口时需要摈弃掉一个误区,pthread_create传递的第四个参数不一定需要是自定义类型、字符串等,可以是自定义的结构体和类对象,线程退出后返回的变量也不一定是整型,也可以是自定义的结构体和类对象。

下面是一个示例代码,pthread_create第四个参数传递一个自定义类对象,pthread_routine也返回一个类对象,通过pthread_join接收:

cpp 复制代码
#include <iostream>
#include <string>
#include <pthread.h>

class Task
{
public:
    Task(int x, int y) : _x(x), _y(y), _result(0), _code(0)
    {}

    void Div()
    {
        if(_y == 0)
        {
            _code = 1;
            return;
        }
        _result = _x / _y;
    }

    void Print()
    {
        std::cout << _result << "[" << _code << "]" << std::endl;
    }

private:
    int _x;     //除数
    int _y;     //被除数
    int _result;//结果
    int _code;  //标识结果是否合法,0合法,1非法
};


void *thread_routine(void *args)
{
    Task* t = static_cast<Task*>(args);
    t->Div();
    
    return t;
}

int main()
{
    pthread_t tid;
    Task* pt = new Task(20, 0);
    pthread_create(&tid, nullptr, thread_routine, pt);

    void *ret = nullptr;
    pthread_join(tid, &ret);
    Task* result = (Task*)ret;
    result->Print();
    return 0;
}

线程分离

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则⽆法释放资源,从⽽造成系统泄漏。
  • 如果不关⼼线程的返回值,join是⼀种负担,这个时候,我们可以通过线程分离告诉系统,当线程退出时,⾃动释放线程资源。
  • 线程分离不能违背线程在进程内运行,即使线程分离了也要在进程内运行。
    线程分离的要调用的库函数:
cpp 复制代码
int pthread_detach(pthread_t thread);

可以是线程组内其他线程对⽬标线程进⾏分离,也可以是线程⾃⼰分离:

cpp 复制代码
pthread_detach(pthread_self())

joinable和分离是冲突的,⼀个线程不能既是joinable⼜是分离的。下面我们用一个示例代码感受一下:

cpp 复制代码
void *thread_routine(void *args)
{
    //自己分离自己
    // pthread_detach(pthread_self());
    std::string name = (const char *)args;
    std::cout << "我是一个新线程:" << pthread_self() << " thread name: " << name << std::endl;
    pthread_exit((void *)11);
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, thread_routine, (void *)"thread-1");
    //主线程分离新线程
    pthread_detach(tid);

    sleep(1);
    void *ret;
    int n = pthread_join(tid, &ret);
    std::cout << "join success: " << (long long)ret << "n: " << strerror(n) << std::endl;

    return 0;
}

运行结果:

线程被取消(pthread_cancel)后通过pthread_join第二个参数获取的退出值是-1,-1是一个宏值:

cpp 复制代码
#define PTHREAD_CANCELED ((void *) -1)

二、C++线程库

C++的线程库具有跨平台性,所以C++的线程库对所有平台自带的线程库做了封装,对linux来说就是封装了pthread线程库,所以在linux下要运行C++多线程在编译时就需要加 -lpthread,示例如下(需要包thread头文件):

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <thread>

void run()
{
    while(true)
    {
        std::cout << "我是一个新线程" << std::endl;
        sleep(1);
    }
}

int main()
{
    std::thread t(run);
    t.join();
    return 0;
}

对于我们开发者角度来说最佳实践是学习linux的pthread库,更贴近原理。写代码用C++线程库,这样写出的代码具有跨平台性。

三、线程ID及进程地址空间布局

1、主线程用的进程虚拟地址空间的栈,新线程的栈都在动态库内部。

2、线程管理不是由进程为单位的,系统中全部LWP都要被统一管理,所以pthread库加载到内存不是单独为某个进程加载的,而是为整个系统加载的。


四、总结

1、linux线程实现是由内核和pthread库共同实现的,内核负责调度,pthread库负责属性保存,上层就是基于此作的封装。

2、线程概念是由pthread库提供的,因为线程的描述结构体在pthread库中。pthread库是在用户层,所以linux线程也叫用户级线程。当线程的描述结构体在内核中该线程就是内核级线程。

五、线程局部存储

在实际多线程工程中,我们不仅需要程序整体的全局变量,也想要针对某个特定线程的全局变量,所以编译器为我们提供了 __pthread 的内置选项,用它修饰后的变量就成了线程的局部存储,每一个线程的局部存储空间中都有一份各自的"全局变量",不同线程之间还互不影响。

一般线程局部存储只支持内置类型。
我们下面区分一下线程中的栈区变量和局部存储(TLS)变量:

栈区变量:适合短期、局部使用的临时数据(比如函数内的循环变量、临时计算值),优点是访问速度快,缺点是作用域和生命周期受限。

TLS 变量:适合需要在线程内全局共享、但线程间隔离的数据(比如线程专属的日志句柄、线程 ID、配置上下文),优点是生命周期长、作用域广,缺点是访问速度略慢于栈变量(需通过寄存器间接访问)。

六、线程封装

下面我们用面向对象的方式封装一下linux的pthread线程库。

我们进行线程封装很重要的一个原因是解决C语言到C++的跨度问题,当我们在类内定义pthread_routine时即使pthread_routine的返回值和参数都是void *时也会报错,因为类内的每一个成员函数的第一个参数都是this指针,当把pthread_routine传给pthread_create的第三个参数时就不符合返回值和参数都要是void *。

首先我们可以在类外定义pthread_routine,但是这样我们访问类的成员变量很不方便,需要通过定义get成员函数获取。所以最佳解决方法就是用static修饰pthread_routine,这样pthread_routine就没有this指针了,与此同时static成员函数由于没有this指针,所以是不能访问非静态成员变量的,所以需要通过pthread_create的第四个参数把this指针传进pthread_routine中,这样pthread_routine就能通过this指针访问非静态成员变量了。

cpp 复制代码
//thread.hpp
#ifndef __THREAD_HPP__
#define __THREAD_HPP__

#include <iostream>
#include <string>
#include <pthread.h>
#include <functional>
#include <unistd.h>
#include <sys/syscall.h> /* For SYS_xxx definitions */

#define get_lwp_id() syscall(SYS_gettid)

using func_t = std::function<void()>;
const std::string defaultname = "None-Name";

class Thread
{
public:
    Thread(func_t func, std::string name = defaultname)
    :_func(func)
    ,_name(name)
    ,_isrunning(false)
    {}

    static void *pthread_routine(void *args)
    {
        //需要把void *类型的args转为Thread *类型
        Thread *self = static_cast<Thread *>(args);
        self->_isrunning = true;
        self->_lwpid = get_lwp_id();
        self->_func(); //回调执行任务
        pthread_exit((void *)0);
    }

    void start()
    {
        int n = pthread_create(&_tid, nullptr, pthread_routine, this);
        if(n == 0)
        {
            std::cout << "create sucess" << std::endl;
        }
    }

    void Join()
    {
        int m = pthread_join(_tid, nullptr);
        if(m == 0)
        {
            std::cout << "join sucess" << std::endl;
        }
    }

    ~Thread()
    {}
private:
    bool _isrunning;
    pthread_t _tid;
    pid_t _lwpid;
    std::string _name;
    func_t _func; //线程要完成的任务
};

#endif
cpp 复制代码
//testThread.cc
#include "Thread.hpp"
#include <vector>

void test()
{
    int cnt = 5;
    while(cnt--)
    {
        std::cout << "新线程运行了" << std::endl;
        sleep(1);
    }
}

int main()
{
    // //单线程
    // Thread t(test, "thread-1");
    // t.start();
    // t.Join();

    //多线程
    std::vector<Thread> Threads;
    for(int i = 0; i < 5; i++)
    {
        std::string name = "thread-";
        name += std::to_string(i + 1);
        Thread t(test, name);
        Threads.push_back(t);
    }

    for(auto &t : Threads)
    {
        t.start();
    }

    for(auto &t : Threads)
    {
        t.Join();
    }

    return 0;
}

以上就是小编分享的全部内容了,如果觉得不错还请留下免费的关注和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~

相关推荐
huangyuchi.1 小时前
【Linux网络】深入理解守护进程(Daemon)及其实现原理
linux·运维·服务器·c/c++·守护进程·会话·进程组
木心爱编程2 小时前
Qt C++ + OpenCV 实战:从零搭建实时视频滤镜与图像识别系统
c++·qt·opencv
橙露3 小时前
Nginx Location配置全解析:从基础到实战避坑
java·linux·服务器
starvapour9 小时前
Ubuntu的桌面级程序开机自启动
linux·ubuntu
哇哈哈&10 小时前
gcc9.2的离线安装,支持gcc++19及以上版本
linux·运维·服务器
咕咕嘎嘎102411 小时前
C++六个默认成员函数
c++
___波子 Pro Max.11 小时前
Linux快速查看文件末尾字节方法
linux
Caster_Z12 小时前
WinServer安装VM虚拟机运行Linux-(失败,云服务器不支持虚拟化)
linux·运维·服务器
亭上秋和景清12 小时前
指针进阶:函数指针详解
开发语言·c++·算法