了解了线程的基本原理之后,我们来学习线程在C语言官方库中的写法与用法。
1. 常见pthread接口及其背后逻辑
1.1 pthread_create
与线程有关的函数构成了⼀个完整的系列,绝⼤多数函数的名字都是以“pthread_”打头的•要使⽤这些函数库, 要通过引⼊头文件 <pthread.h>•链接这些线程函数库时要使⽤编译器命令的“-l pthread”选项![]()
pthread_create
函数并不是Linux提供的创建线程的系统调用,而是Linux中对轻量级进程已有的接口进行封装的动态库中的函数
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;void *routine(void *arg)
{//值得注意的是,线程执行的函数的返回值一定是一个void*,用于返回一个指针-》这样的目的是让所有类型的参数都能被返回//包括但不限于类对象,函数,容器等等。//而参数void*,是一种泛型编程的实现方式,这样一来可以根据传入的内容来指定完成任务。while (true){cout << "new thread" << endl;sleep(1);}
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, run, (void *)"thread-1");while (true){cout << "main thread" << endl;sleep(1);}return 0;
}
由实验结果可知,main和new thread的执行顺序并不固定。
两个线程是异步打印(使用)到同一个文件资源(显示器)上,所以存在打印错乱的情况,但是的确可以做到每一个线程完成自己的任务而不会受到其他线程的干扰,这两个线程就是两个不同的执行流,而因为
routine
函数始终在访问同一个资源,所以此时其是不可重入函数。
pthread_create的底层其实是clone-------一个非常复杂不便于使用的函数。
由于Linux在内核代码中并没有将进程和线程完全分成两个系统管理,所以clone的flag参数就有以下两种选择,创建一个parent(进程),或者是只创建一个轻量级进程(clone_thread)
创建进程还是创建线程?一个文字游戏而已。
了解:clone第一个参数,即传给该线程的函数,第二个是栈空间(那么多虚拟内存的段,为什么要单独传一个这?),最后一个是线程要执行的函数的参数。。。。。。。
所以,现在就可以进一步理解用户级线程
pthread_create调用的是clone,clone其实也是被封装的,底层还有如do_clone等函数。
最后,关于pthread_create的返回值:
pthreads函数出错时不会设置全局变量errno(⽽⼤部分其他POSIX函数会这样做)。⽽是将错 误代码通过返回值返回。没有出错返回0,出错返回错误码。
对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要⽐读取线程内的errno变量的开销更小。(strerror和errno都是之前介绍进程的时候提及的概念)
1.2 pthread_self
pthread_self用于查看任意线程的线程ID
在上一文中,我们提到以下指令,用于查看:
ps -aL | head -1 && ps -aL | grep thread
或者
while :; do ps ajx |head -1 && ps ajx | grep test | grep -v grep;sleep 1;echo"------------------------------------------------";done
这个线程的tid值似乎与之前提到的lwp不太一样呢???
请记住这个伏笔,会在之后的内容中重点聊。
如果觉得这个数字太长,我们可以:
string ToHex(pthread_t tid)
{char buffer[64];snprintf(buffer,sizeof(buffer),"0x%lx",tid);return buffer;
}
这个tid似乎看着很像地址????????
多线程状态对于资源的使用
能够使用以上两个接口,我们便能创建一些多线程的demo。
下面我们来验证一下多线程的栈区是否是独立的:
//..................... void *run(void *arg) {//值得注意的是,线程执行的函数的返回值一定是一个void*,用于返回一个指针-》这样的目的是让所有类型的参数都能被返回//包括但不限于类对象,函数,容器等等。//而参数void*,是一种泛型编程的实现方式,这样一来可以根据传入的内容来指定完成任务。string name = static_cast<const char*>(arg);int val = 0;while (true){cout << "new thread,name : "<<name << " my_threadID : "<< ToHex(pthread_self())<<"my val : "<<val<<endl;val++;if(name=="thread-1") val++;sleep(1);} }int main() {pthread_t tid1;pthread_t tid2;pthread_create(&tid1, nullptr, run, (void *)"thread-1");pthread_create(&tid2, nullptr, run, (void *)"thread-2"); //...........
实验现象也不出所料:两个栈的val值互不影响。
两个新线程都在修改
val
,但是二者并没有影响到另外的val
,这也验证了每个线程实际上也有自己的栈。再尝试让两个线程都修改同一个全局变量:
果然,两个线程在某种意义上已经形成了“通信”。
将
val
变量作为了全局变量供两个线程进行访问,此时两个线程均看得到这个变量,所以这个变量就是两个线程的共享资源,与前面显示器打印问题一样,因为共用并且没加保护,导致一个线程的修改会影响到另一个线程,这个效果同样适用于静态变量
两个线程谁先执行不一定,并且不同的线程会瓜分时间片
其实,独立的线程不代表是私有的线程,强行使用地址去访问某一个线程栈的内容依然能访问到。
结果:
但是这样的做法是非常不推荐的,是错误的。也在一定的程度上的说明,只要拿到虚拟地址就能访问,一切的“不允许”都是被规则规定的。
线程局部存储
如果希望一个变量被每一个线程都单独创建副本,则使用__thread来修饰该变量。该关键词只能用于修饰内置类型。
栈的特性与存储
栈是每个线程私有的内存区域,用于存储局部变量、方法参数和返回地址等。每个新线程都会创建自己的栈。栈上的数据独立于其他线程,这意味着如果一个线程在其栈中创建了一个变量,其他线程不能直接访问该变量,除非通过方法参数或共享对象显式传递。这种独立性提供了天然的线程安全,因为每个线程都有自己的栈空间,不会无意间影响其他线程的数据。
堆的特性与存储
堆是一个全局共享的内存区域,用于动态分配内存。所有线程都可以访问堆上分配的对象。如果一个对象在堆上被创建,并且其引用(内存地址)被传递给多个线程,那么这些线程就可以同时访问甚至修改该对象的内容。由于堆是共享的,所以在多线程环境下访问堆上的对象时需要特别小心,通常需要使用同步机制来避免竞态条件或数据不一致的问题。
总之,不论是堆上的数据还是栈上的数据,只要能获取到指定内容的地址,就不存在所谓的栈独立性,此刻栈和堆都是共享的,而所谓的栈独立性和堆共享性更强调所有的线程都有自己的栈,但是堆共有一个
1.3 pthread_join
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会也不能复⽤刚才退出线程的地址空间。为了解决类似于进程中“僵尸进程”的问题,并且获得线程的任务执行情况。被主线程创建的线程一般需要被等待并且回收。
参数 :thread: 线程 ID(希望被join掉,也就是希望被终止的线程ID)value_ptr: 它指向⼀个指针(双重指针),是线程执行的函数的返回值的地址。返回值:成功返回 0 ;失败返回错误码。
比如我们现在不需要线程执行的任务的返回值:
pthread_join的第二个参数是一个输出型参数,因为线程执行的函数是一个void*类型的返回值,为了让这样的void*参数能够自动适应所有的返回值,所以必须使用一个void**的参数
线程执行的任务想返回一个10,所以就用void*强转一下10(ret是一个void*,用于接受这个10的值,所以10必须强转成void*)。
void*是8个字节(现在的linux机器或者其他机器几乎都是64位),此时10被当成了一个void*
所以用int去强转会出现报错,应该采用long long去强转。
或者用一个堆指针来完成这个任务也可以,先把void*的指针变成int*,再解引用。
一个线程只要return,就退出了
一个void**类型的变量ret去记录线程执行的函数的返回值(return的值)
错误使用:
这么做虽然能正确找到这个ret值,但是是不科学的,因为a是一个栈上的变量,按理来说这个变量会销毁。更建议使用堆指针。
以上代码说明说明堆空间是共享的。线程之间能拿到其他线程创建的堆资源,不过后续需要自己销毁资源
另外,直接pthread_join自己会报错:
既然pthread_create的回调函数支持传入任何参数,那么假设我们希望给每一个线程传一个结构体,并且打印、操作该结构体对应的数据。
一个简单的传结构体指针demo:
class ThreadData
{
public:ThreadData(const string &name, int a, int b): _name(name), _dataA(a), _dataB(b){}pair<int, int> GetInfo(){return pair<int, int>(_dataA, _dataB);}const int GetA(){return _dataA;}void Exec(){_dataA++;_dataB *= 10;cout << _name << endl;}~ThreadData() {}private:string _name;int _dataA;int _dataB;
};void *routine(void *th_data)
{ThreadData *p_data = (ThreadData *)th_data;//while (true)//{cout << "new thread,myID: " << ToHex(pthread_self()) << " A:B " <<p_data->GetInfo().first << " : " << p_data->GetInfo().second << endl;p_data->Exec();cout << "new thread,myID: " << ToHex(pthread_self()) << " A:B " << p_data->GetInfo().first << " : " << p_data->GetInfo().second << endl;sleep(1);//}return (void*)p_data;
}int main()
{pthread_t tid;ThreadData *th_p = new ThreadData("thread-1", 10, 20);int err_n1 = pthread_create(&tid,nullptr,routine,(void*)th_p);if(err_n1!=0){cerr<<"create error "<<strerror(err_n1)<<endl;return 1;}else {cout<<"create success"<<endl;}void* ret = nullptr;int err_n2 = pthread_join(tid,&ret);if (err_n2 != 0){cerr << "join error" << strerror(err_n2) << endl;}elsecout << "joined success! " << "ret num A : " << ((ThreadData*)ret)->GetA() << endl;
}
创建多个线程的demo:
之前的ThreaData就是为了在一个新的线程的时候有对应的数据和方法
pthread_join在进行的其实也是一种阻塞等待,这会影响主线程的工作效率。
1.4 线程分离pthread_detach
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进⾏pthread_join操作,否则⽆法释放资源,从⽽造成系统泄漏。•如果不关⼼线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,⾃动释放线程资源
join和detach是矛盾的。
需要注意的是,在多执行流的情况下,一定要确保主执行流最后退出,防止主执行流先退出导致 分离的线程 需要的资源被释放导致错误。线程可以自主分离(在线程的执行函数中执行pthread_detach(pthread_self()); 或者被迫分离-》主线程等其他线程调用该函数。
线程程序替换
注意,进程程序替换是不可以直接发生在线程执行流中的,因为程序替换的本质是替换掉当前进程的代码,此时线程的代码也会被替换从而导致错误,但是可以在线程中创建子进程,再在子进程中使用进程程序替换
1.5 线程退出
首先,不能直接用exit(),因为exit()会直接让整个进程都退出。
pthread_exit(),pthread_exit的参数也会被pthread_join给拿到----------->因为pthread_join本来就是拿的return的值,现在由pthread_exit完成这个“return”的任务。
pthread_cancel
pthread_cancel
:主要用于主线程去取消其他进程。(类似于父进程去取消子进程的过程)
但是不建议直接使用,因为不清楚被取消的线程的工作状态
一个被pthread_cancle取消的线程的join值是-1(ret 返回的值)
这个接口要慎用,如果在新线程未完成任务就被主线程结束,主线程可能也会结束。
并且主线程在cancle的时候可能不清楚新线程的状态,有可能出错。
一个被pthread_cancle取消的线程的join值是-1(ret )
2. CPP的封装
除了C语言对系统的相关线程操作进行了封装外,其他编程语言也会做同样的事情,这样可以确保语言具有可移植性,同样,C++中也有对应的线程库<thread>,具体接口见C++线程库部分,但是需要注意的是,如果是在Linux下使用C++线程库编写代码,编译该代码时依旧需要携带-lpthread,因为C++线程库本质也是封装了libpthread.so
错误:
lambda表达式构造thread: