教材观点是这样的:线程是一个执行分支,执行力度比进程更细,调度的成本更低。
Linux内核观点:进程是系统分配资源的基本单位,线程是CPU调度的基本单位。
这两种说法都是正确的,但是我们究竟该如何理解线程呢?
在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。一切进程至少都有一个执行线程。线程在进程内部运行,本质是在进程地址空间内运行。在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
在Linux中其实本质上并不存在线程,而是将轻量级进程作为线程。其本质就是OS并没有给线程创建自己独立的地址空间,而是与进程共用一套地址空间,那么这也就注定了线程中绝大部分资源是可以共享的。这样设计的好处是复用了PCB的那一套设计,线程的TCB可以用进程的PCB模拟出来,这样的设计更加简单并且维护效率更加高效,像服务器等开发选择Linux的原因就是因为Linux可以在长时间的服务中运行。Windows中的线程才算的上是一种严格的线程,Windows的线程并没有复用进程的方法,而是创造了真正意义上的线程。
线程的优点:
线程的缺点:
这里再补充一个小知识点:多线程中页表关系是怎样维护建立的呢?
我们知道,在X86的环境下,我们最多可以拥有232种虚拟地址,而将虚拟地址转化成物理地址的页表大小应该为多少呢?
如果按照每一个虚拟地址都建立一个对应映射的话,假设用一个四字节的整形变量int来维护,那么不算其他的,只算虚拟地址到物理地址的映射,那么至少得需要232 *8(大约32GB),那我们操作系统还玩不玩了,这样设计肯定是不合理的。
实际上,操作系统将每一个32比特位的虚拟地址做了如下划分:
这样进行页表大小计算时我们用的是前20个比特位,也就是220 ,然后通过下面方式进行页表映射:
那么最后12位到哪里去了呢?最后12位的虚拟地址是我们将虚拟地址转化位物理地址的偏移量,这个偏移量的大小恰好是212 (4KB),这个4KB是操作系统管理物理内存的单位,相信大家对于4KB一点儿也不陌生,因为我们讲解文件系统的时候磁盘与内存进行交互的单位也是以4KB位单位。这里为什么要使用4KB的大小进行交互而不是以字节进行交互呢?因为一个很著名的原理:局部性原理。通俗的来说,局部性原理就是预测未来CPU高速缓存的命中情况来提升效率。
所以通过这种方式我们只用了220 量级的大小空间来完成页表的建立,最多也就几MB而已,更何况并不是所有地址都会被用到,实际用到的地址可能只有几十字节大小。所以这种方式可以解决操作系统如何为页表分配合适的空间问题。
那么实际操作系统是如何分配资源给对象的呢?比如我们使用malloc一个资源是立马就会给你开空间的吗?显然不是这样的,操作系统为了高效是不会直接立马给你开空间的,而是产生一个缺页中断,当你真正使用该空间时才会去开空间。
线程异常:
这里我们可以简单的验证一下,大家先可以先看看时如何创建线程的,后面我们会详细的讲解:
比如下面的我们让线程1出错:
Makefile:
mytest:Test.cpp
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -rf mytest
Test.cpp:
#include
#include
#include
using namespace std;
void *Run1(void *argv)
{
int cnt = 4;
while (true)
{
cout << "I am t1,is running" << endl;
sleep(1);
if (--cnt == 0)
{
char *str = "abcd";
*str = 'Q';
}
}
}
void* Run2(void* argv)
{
while(true)
{
cout<<"I am t2,is running"<<endl;
sleep(1);
}
}
int main()
{
pthread_t t1,t2;
pthread_create(&t1,nullptr,Run1,nullptr);
pthread_create(&t2,nullptr,Run2,nullptr);
while(true)
{
cout<<"I am main,is running"<<endl;
sleep(1);
}
return 0;
}
这里面值得注意的细节:创建线程时要引入头文件
;链接时为了能够找到库,在Makefile种要指定库的名称-lpthread
,要查找指定进程的所有线程可以使用下面命令:ps -aL | grep 进程名称
;想要显示更加详细信息可以使用下面命令:ps -aL | head -1 && ps -aL | grep 进程名称
;为了方便观察我们可以使用下面的命令脚本:while :;do ps -aL | head -1 && ps -aL | grep mytest;echo "************************************";sleep 1;done
我们来运行下观察下结果:
从图片中我们不难发现当其中一个线程崩溃而导致整个进程(所有线程)都挂掉了。
线程用途:
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
1️⃣文件描述符表
2️⃣每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
3️⃣当前工作目录
4️⃣用户id和组id
;-lpthread
”选项.功能:创建一个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg)
参数:
thread:返回线程ID;
attr:设置线程的属性,attr为nullptr表示使用默认属性;
start_routine:是个函数地址,线程启动后要执行的函数;
arg:传给线程启动函数的参数;
返回值:成功返回0;失败返回错误码。
这里再补充一个错误检查的知识点:
我们观察上面创建线程的接口中,第一个参数是线程的id,这个的数值与之前我们在用监视脚本看的线程PID不太一样,现阶段可以理解为同一线程的两种不同的身份形式(比如我们在学校的学生证和处于社会中的身份证类似);第二个参数我们一般设置为空;第三个是一个参数为void*,返回值为void的函数指针;第四个参数是一个void的对象,我们一般是将线程的信息通过该参数传递进去的。
我们根据上面介绍就可以写出如下代码:
#include
#include
using namespace std;
void* Run(void* args)
{
const char* name=static_cast<char*> (args);
while(true)
{
cout<<name<<"is running"<<endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t pids[5];
for(int i=0;i<5;++i)
{
char name[26];
snprintf(name,sizeof(name),"pthread%d:",i+1);
pthread_create(pids+i,nullptr,Run,name);
}
while(true)
{
cout<<"I am is main thread,is running"<<endl;
sleep(1);
}
return 0;
}
当我们运行时:
为啥跟我们预计的不太一样呀?我们想要的是打印pthread1 pthread2……这样的数据呀,为啥打印出来的都是pthread5呢?
这其实与我们传入的数组有关:
我们在这里传入的是数组名,也就是首元素地址,我们传给线程创建的参数并不是一个缓冲区而是一个数组的地址,由于创建线程是先将线程创建出来,并不会立马去执行线程中的代码,而我们每次传入的地址(数组名)是相同的,所以最后一个线程的数据就被保存到了数组中,当我们并发执行线程中的代码时读到的就是数组中的数据(也就是最后一次修改数组中的数据)。那我们如何解决这种现象呢?
我们可以在堆上开辟空间,这样我们每次new出来的地址是不同的,所以就不会出现覆盖的情况了。
比如我们可以这样修改:
当我们再次运行时:
这里面打印顺序并不是1 2 3 4 5那样的原因是因为线程的调度也是不确定的,谁先调度完全是由调度器所决定的。
其实上面传入的对象大家可以更具需求设置的更加完善一些,我们可以封装一个类,让多线程帮助我们完成不同的任务,我这里就不再多写了,大家有兴趣可以根据自己的需求下去完善。
线程ID及进程地址空间布局:
pthread_ sel
f函数,可以获得线程自身的ID.但是其实上面的代码中还存在一个很严重的问题,我们在学习进程中知道,父进程会wait子进程,否则就可能造成了内存泄漏。线程也是一样的,已经退出的线程,其空间没有被释放,仍然在进程的地址空间内;创建新的线程不会复用刚才退出线程的地址空间。主线程必须要回收其他线程的资源,否则就会造成内存泄漏,那回收其他线程的接口是啥呢?
我们来看看官网对pthread_join
的介绍:
功能:等待线程结束
原型:
int pthread_join(pthread_t thread, void **value_ptr);
参数:
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
第一个参数比较好理解,那么第二个参数是一个二级指针,接受的是一个线程的返回值,那么我们知道创建线程的参数里面有一个函数指针,该函数指针的返回值为void*,而这个返回值就可以传递给join的第二个参数使用,比如我们看看下面的代码:
void *Run(void *args)
{
const char *name = static_cast<char *>(args);
cout << "thread1 is running" << endl;
sleep(2);
return (void *)11;
}
int main()
{
pthread_t p1;
pthread_create(&p1, nullptr, Run, nullptr);
cout << "I am is main thread,is running" << endl;
sleep(2);
void* ret=nullptr;
pthread_join(p1,&ret);
cout<<"new pthread exit "<<ret<<endl;
return 0;
}
当我们运行时:
我们发现我们通过返回值返回的11被join给接收到了。
除了使用return 这种方式,我们还可以使用哪种方式终止线程呢?我们还可以使用pthread_exit
接口来处理:
功能:线程终止
原型:
void pthread_exit(void *value_ptr);
参数:
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
除了上面我们讲解的这两种方式外,我们还可以使用pthread_cancel
取消一个执行中的线程:
功能:取消一个执行中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
thread:线程ID
返回值:成功返回0;失败返回错误码
假如我们想要取消自己呢?我们如何得到自己的pid,我们可以使用pthread_self()
:
我们下面来看看线程取消的基本用法:
void *threadRun(void* args)
{
const char*name = static_cast<const char *>(args);
int cnt = 5;
while(cnt)
{
cout << name << " is running: " << cnt-- << " obtain self id: " << pthread_self() << endl;
sleep(1);
}
pthread_exit((void*)11);
// PTHREAD_CANCELED; #define PTHREAD_CANCELED ((void *) -1)
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRun, (void*)"thread 1");
sleep(3);
pthread_cancel(tid);
void *ret = nullptr;
pthread_join(tid, &ret);
cout << " new thread exit : " << (int64_t)ret << "quit thread: " << tid << endl;
return 0;
}
不难发现程序3s后直接退出了,其实也很好理解,因为我们退出的是主线程,所以肯定会直接退出的。
所以我们可以总结线程退出有三种方式:
调用pthread_join函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
所以此时我们可以使用pthread_detach
:
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
我们来验证下:
void* Run(void* args)
{
pthread_detach(pthread_self());
const char* name=static_cast<char*> (args);
cout<<name<<" is running"<<endl;
return nullptr;
}
int main()
{
pthread_t p1;
pthread_create(&p1,nullptr,Run,(void*)"thread1");
int ret=pthread_join(p1,nullptr);
if(ret==0)
cout<<"wait success"<<endl;
else
cout<<"wait fail "<<endl;
return 0;
}
当我们运行时:
为什么是运行success呀?不是说joinable和分离是冲突的吗?按道理这里应该会join失败的呀。
这是由于执行时是先执行的join,此时线程还没有被分离,自然就能够join成功了,我们可以像下面这样写,就会join失败:
当我们再次运行时:
首先我们来看看一张图:
通过之前动静态库的知识我们知道,pthread库是加载到共享区的,那么也就决定了进程中所有线程都是可以访问得到该库的。但是从上图我们看见了有一个主线程栈的空间,这个空间又是为谁准备的呢?
其实这个空间是为主线程准备的,我们之前讲过其余线程中的栈是相互独立的,而这个独立栈的空间就开辟在共享区中,也就是独立栈的空间其实是由库帮助我们开辟的。上图右边第一个struct_pthread又是什么鬼呢?这个是管理共享区中线程的一种数据结构,类似于进程中的PCB。至于什么是局部存储,我们可以来写一个程序看看:
int g_val=20;
void* Run(void* args)
{
const char* name=static_cast<char*> (args);
while(true)
{
cout<<"g_val:"<<g_val<<"&g_val:"<<&g_val<<endl;
sleep(1);
}
}
int main()
{
pthread_t pids[5];
for(int i=0;i<5;++i)
{
char* name=new char[32];
snprintf(name,32,"pthread%d:",i+1);
pthread_create(pids+i,nullptr,Run,name);
}
for(int i=0;i<5;++i)
{
pthread_join(pids[i],nullptr);
}
return 0;
}
当我们运行时:
这也符合我们的预期,因为全局变量是所有线程共享的,但是当我们在全局变量前加上了__pthread
后:
当我们运行时:
我们惊奇的发现居然地址不一样了,这其实就是将g_val分别保存了一份在各自的独立栈中。至于为什么打印出来的数据无规律是因为多线程并发访问的问题,我们后面在详细讲解。