前言:本节内容为线程的控制。在本篇文章中, 博主不仅将会带友友们认识接口, 使用接口。 而且也会剖析底层,带领友友们理解线程的底层原理。 相信友友们学完本节内容, 一定会对线程的控制有一个很好的把握。 那么, 现在开始学习吧!
ps:本节设计线程的概念和进程的知识, 建议友友们了解相关知识点后再来观看!
目录
pthread库
线程控制
线程创建
pthread_create接口
实验
全局变量与多线程
多线程发生异常
编辑 tid
线程等待
pthread_join接口
retval
线程的终止
pthread_exit接口
pthread_cancel接口
使用自定义类作为线程接收对象
线程库的底层原理
线程栈
pthread库
我们知道, 所有的线程, 都属于同一个进程。 所有的线程让他们去打印PID, 那么打印出来的PID最终会是同一个PID。 但是, 我们的线程如果被调度, 那么他就要有一个供别人调用的属于自己的ID。
那么内核当中,有没有很明确的线程的概念呢? 没有, 内核当中只有一个“轻量级进程”的概念。 但是, 并不影响我们的每一个执行流(线程) 都有属于自己的ID, 这个ID叫做tid。
那么, 既然内核中只有“轻量级进程”的概念, 那么他是不是就不会给我们提供线程的系统调用, 只会给我们提供轻量级进程的系统调用!——但是我们用户要使用线程的创建方法, 所以linux程序员, 就在系统和用户层之间开发出了一个pthread线程库。 这个库是在应用层的。是对轻量级进程的相关接口进行封装, 为用户提供直接控制线程的接口。 (几乎所有的linux平台, 都是默认自带pthread库的!linux中编写多线程代码, 需要使用pthread库!)
线程控制
线程创建
pthread_create接口
第一个参数是输出型参数, 线程的id。 第二个参数是线程的属性, 一般情况下我们设置称为nullptr就行了。 第三个参数是一个函数指针, 返回值是void*, 参数也是一个void*, 这个函数指针是一个回调函数。 第四个参数, 就是创建线程成功的时候需要参数。 这个参数就是给线程函数传递的。 返回值0表示成功, 非零表示错误。
实验
下面我们使用一下这个接口, 创建一个新线程。
#include<iostream>
#include<pthread.h>
#include<unistd.h>using namespace std;void* threadRoutine(void* args)
{while (true){cout << "new thread, pid: " << getpid() << endl; sleep(2); }
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRoutine, nullptr); while(true){cout << "main thread, pid: " << getpid() << endl;sleep(1);}return 0;
}
那么既然创建的新线程去执行了新的函数, 那么就注定了这两个线程一个线程执行main函数, 访问的是main函数的代码;另一个线程执行threadRoutine函数, 访问的是threadRoutine函数的代码。 然后我们运行结果如下:
上面这是链接式报错。 这是因为我们这里用的接口不是系统调用, 是库方法!!不是c/c++库, 是第三方库。 我们在学习动态库的时候学过, 这里必须我们自己指定链接哪一个库。 那么链接哪一个库呢? 起始man手册里面已经告诉我们了, 如下图:
就是链接这个-pthread库。 我们在makefile中, 链接上这个库:
然后就能编译成功了:
运行后, 我们可以看到打印出来的PID是一样的:
同时, 我们如果使用ps axj也能看到进程也只是一个:
我们想要查到两个执行流, 怎么做呢? 这里有一个选项叫做ps -aL(a表示所有, L可以理解成light)
图中我们画的这个LWP是什么东西呢? 其实, 在我们的linux中, 并没有真正意义上的线程, 是使用的进程模拟的线程。 cpu调度的时候, 不仅仅只看我们的PID, 每一个轻量级进程也有一个自己的标识符, 就是这个LWP(light weight process id)。所以, cpu在调度进程的时候根部看的不是PID, 看的是LWP!!!
但是, 我们可以看到, 这个执行流里面, 有一个执行流的PID和LWP是相同的!!!这是怎么回事? ——PID和LWP相同, 就意味着这个进程叫做主线程。 剩下PID和LWP不相等的是被创建出来的线程。
另外, 我们发送信号, 如果是给一个线程发, 那么同一个进程内的其他线程同样会挂掉。 就如同下图:
全局变量与多线程
我们创建一个全局变量g_val, 让g_val在主线程进行加加操作。 然后主线程和父线程都对这个g_val进行打印。代码如下:
#include<iostream>
#include<pthread.h>
#include<unistd.h>using namespace std;int g_val = 100;void* threadRoutine(void* args)
{while (true){printf("new thread pid: %d, g_val: %d, &g_val: 0x%p\n", getpid(), g_val, &g_val);sleep(1); }
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRoutine, nullptr); while(true){printf("main thread pid: %d, g_val: %d, &g_val: 0x%p\n", getpid(), g_val, &g_val);sleep(1);g_val++;}return 0;
}
运行后我们就会发现g_val会逐渐增大!!同时, 新线程也能够看到这个值会变化。 也就是说, 全局变量, 对于所有的线程来说是可见的!
多线程发生异常
如果多线程发生了异常, 不管是哪一个执行流发生异常, 都会导致进程退出。 下面是测试代码(五秒后新线程发生除零错误, 异常, 进程退出):
#include<iostream>
#include<pthread.h>
#include<unistd.h>using namespace std;int g_val = 100;void* threadRoutine(void* args)
{while (true){printf("new thread pid: %d, g_val: %d, &g_val: 0x%p\n", getpid(), g_val, &g_val);sleep(5); int a = 10;a /= 0;// cout << "new thread, pid: " << getpid() << endl; // show("[new thread]");}
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRoutine, nullptr); while(true){printf("main thread pid: %d, g_val: %d, &g_val: 0x%p\n", getpid(), g_val, &g_val);// cout << "main thread, pid: " << getpid() << ", g_val: " << g_val << ", &g_val: " << &g_val << endl;// show("[main thread]");sleep(1);g_val++;}return 0;
}
运行结果:
tid
在上面的实验里面, 我们没有打印过tid。 我们现在以十六进制打印一下这个tid。
然后运行就能看到显然我们的tid和LWP是不一样的。
这是因为LWP是操作系统层面的概念, 作为操作系统自己知道即可, 我们用户并不关心LWP, 我们只关心tid。而这个tid是什么, 其实就是共享区的一块地址(涉及到了底层原理, 后面会讲到)
线程等待
pthread_join接口
我们先考虑这样一个问题, 对于一个新线程和一个主线程来说, 是主线程先退出, 还是新线程先退出? 如果是进程, 我们知道, 大部分情况下是子进程先退出,因为如果父进程先退出, 新线程就会一直僵尸, 造成内存泄漏。 其实, 我们的线程也是一样的, 线程退出后也需要被等待, 如果不等待, 就会造成类似于僵尸进程的问题。(但是我们使用ps -aL是观察不到的)
而且我们等待线程和等待进程的目的类似, 有两个:
- 防止新线程造成内存泄漏。
- 如果需要, 获取新线程的退出结果。
那么, 等待函数如何使用, 我们使用man手册:
这里的第一个参数就是被等待线程的tid。第二个参数是线程的返回值。 返回值是int类型, 成功返回零, 失败返回错误码。(注:我们用的这套线程的接口, 它的函数, 几乎都是以pthread开头。 线程类函数里面, 所有的出错码不用errno, 统一使用返回值的方式进行返回。)
retval
现在我们讨论一下这个第二个参数, 这个第二个参数其实就是拿到我们的新线程的返回值。 我们如果仔细观察就会发现, 我们的的我们的新线程是以回调函数的方式传给pthread_create的, 所以我们无法获得新线程的返回值。 而如果想要拿到这个返回值, 就要用到等待线程时的第二个参数, 这个参数的原理是什么? 下面讲解一下:
首先, 我们的线程的返回值是在pthread_create里面的。 也就是说,我们的线程的返回值, 返回到了pathread库的接口里面。 那么pthread同时又有一个接口, 叫做phtead_join, 这个接口的第二个参数是一个输出型参数, 它可以拿到phtread_create里面的对应线程的返回值。 所以, 我们想要获得这个返回值, 就要先在外部定义一个void* 类型的接收变量, 然后将这个变量的地址传给pthread_join的第二个参数, 就相当于将等待函数内部的想要给我们带出来的值给我们带出来。 而phtread_join想要给我们带出来的是什么? 就是这个新线程的返回值!!!所以, 我们就能拿到新线程的返回值了。
线程的终止
pthread_exit接口
首先我们需要知道的是, exit是用来终止进程的, 不能用来终止线程。 如果使用exit终止线程, 会让我们的整个进程都退出。
线程库为我们提供了线程的退出方法:pthread_exit。
参数类似于exit里面的参数。 就是退出码。 也类似于返回值。
下面为简单的代码测试
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<cstdlib>
using namespace std;void* threadRuntine(void* args)
{string name = static_cast<char*>(args);int cnt = 5;while (cnt--){cout << args << " say#: " << "I am a new thread" << endl;sleep(1);}pthread_exit((void*)1);
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRuntine, (void*)"thread 1");void* ret;pthread_join(tid, &ret);cout << (long long int)ret << endl;return 0;
}
pthread_cancel接口
这个函数的作用是给新线程发送一个取消请求, 并且退出的线程, 退出码为-1。(注意, 不常用)
void* threadRuntine(void* args)
{string name = static_cast<char*>(args);int cnt = 5;while (cnt--){cout << args << " say#: " << "I am a new thread" << endl;sleep(1);}pthread_exit((void*)1);
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRuntine, (void*)"thread 1");//两秒后就直接退出sleep(2);pthread_cancel(tid);//void* ret;pthread_join(tid, &ret);cout << (long long int)ret << endl;return 0;
}
使用自定义类作为线程接收对象
我们可以使用自定义类型作为线程的接收对象。
我们使用一下自定义类型的对象, 实验过程为: 首先定义两个类, 一个Request, 一个Response。 其中Request的成员变量有一个start, 一个end。 我们新线程就是来计算从start到end的总和。 然后就是Response, Response用来新线程的返回。里面的成员包含一个_result, 一个_exitcode。
#include<iostream>
#include<unistd.h>
#include<string>
#include<cstdlib>
#include<pthread.h>
using namespace std;class Request
{
public:Request(int start, int end, string threadname):_start(start),_end(end),_threadname(threadname){}public:int _start;int _end;string _threadname;
};class Response
{
public:Response(int result, int exitcode):_result(result),_exitcode(exitcode){}public:int _result;int _exitcode;
};void* threadRuntine(void* args)
{Response* rsp = new Response(0, 0);Request* req = static_cast<Request*>(args);for (int i = req->_start; i <= req->_end; i++){rsp->_result += i;usleep(100000);cout << ".exe is running..." << i << endl;}return (void*)rsp;
}int main()
{Request* req = new Request(1, 100, "thread 1");pthread_t tid;pthread_create(&tid, nullptr, threadRuntine, (void*)req);void* ret;pthread_join(tid, &ret);cout << "result: " << static_cast<Response*>(ret)->_result << endl;return 0;
}
运行结果:
线程库的底层原理
我们理解线程库的大概底层原理, 其实只要理解下面博主画的这张图即可:
就是说, 我们的pthread库是在用户层的。 然后呢, 我们的线程库里面,就保存着我们的线程要执行的方法, 以及独立栈。 什么意思, 这里的方法其实就是我们pthread_create里面的第二个参数, 它是由我们的线程库所维护的。 我们的线程库维护执行方法以及独立栈, 然后将他们作为第一, 第二参数传给系统调用clone。 然后这个执行方法要暴露给我们的用户, 让我们的用户来定义轻量级进程要执行的代码。 所以, 线程的概念是由库给我们维护的(线程在底层对应的轻量级进程的执行流。 但是我们线程中很多用户关心的字段, 属性由库来维护,比如独立栈, 比如运行的代码。)。 所以, 当我们执行自己写的多线程的代码的时候, 我们的库要不要加载到内存中? ——一定要的, 因为我们的pthread是一个动态库, 所以pthread一定要先加载到内存中,然后通过页表共享, 映射到我们进程地址空间的共享区。
我们上面说了, 线程库要维护线程的各种属性。 那么我们的线程这么多, 线程库要维护他们。 注意, 维护, 其实本质就是管理。 所以, 我们的线程库势必要先描述, 再组织。 所以, 在线程库当中, 每创建一个线程, 就要创建一个对应的线程控制块。 这里面有很多字段, 比如独立栈在哪里, 回调函数在哪里, 线程id是什么, LWP指向底层的哪一个执行流。 并且, 未来, 我们的用户使用这个线程控制块, 系统就能直接访问下层的之心六, 执行对应的代码, 所以, 这个也叫做用户级线程!!
线程栈
我们再来谈一下线程栈:就是, 每一个线程在创建的时候, 都有一个独立的栈结构。 这是因为每个线程都有一个自己的调用链。 也就注定了每一个线程都要有自己独立的调用链所对应的栈帧结构。 这个栈结构里面会保存任何执行流在运行过程中的所有的临时变量, 所以, 每一个线程都要有自己的独立的栈结构。
其中, 主线程直接用我们的地址空间里面提供的栈结构即可, 其他的都是用线程库里面提供的独立栈结构, 大概的做法就是首先在库里面为新线程创建要给描述线程的线程控制块。 这个线程控制块的起始地址就是自己的线程的tid。 这个里面有一块默认大小的空间, 这个空间就叫做线程栈。 然后就要在内核中创建执行流了, 就是在库里面调用clone, 然后把线程执行的方法, 以及刚刚创建的线程栈作为第一,二参数传递给clone。 所以, 除了主线程, 所有其他线程的独立栈, 都在共享区。 具体来讲是在pthread库中,tid指向的用户tcb中!!
——————以上就是本节全部内容哦, 如果对友友们有帮助的话可以关注博主, 方便学习更多知识哦!!!