线程概念
线程这个词或多或少大家都听过,今天我们正式的来谈一下线程;
在我一开始的概念中线程就是进程的一部分,一个进程中有很多个线程,这个想法基本是正确的,但细节部分呢我们需要细细讲解一下;
什么是线程
1.线程是进程执行流中的一部分,就是说线程是进程内部的一个控制序列;
2.线程是操作系统调度的基本单位;
3.在linux中没有真正意义上的线程,也就是操作系统中说的tcb(thread ctrl block),但是其他的操作系统是有的不同的操作系统实现不同(如windows就是在pcb下再次构建了tcb的数据结构);为什么linux下没有真正意义的线程呢?因为线程再操作系统中也是需要被管理的,可是线程的管理一定得创建数据结构,创建复杂的数据结构一定需要增加维护的成本与难度,而线程的管理其实和进程是相似的;所以聪明的linux程序员将线程管理设计为了轻量化的进程,将线程与进程统一管理,减轻了代码的复杂度,便于维护提高效率;(线程粒度细于进程)
4.线程其实是进程的一部分,所以线程运行的地方就是在进程的虚拟地址空间中的;因为线程本身也是属于进程的一部分的,只是被加载到了进程队列中运行而已;(进程就像是一个家庭,线程就像是家庭中的每一个人,每个人都有自己的工作,所以需要分开执行,也就是处于进程队列中),进程会分配的资源给线程(家庭中的资源会分配给每个人,比如爸爸要去远的地方工作需要开车,那车子这个资源就会分配给父亲),这个资源包括代码和数据,之前我们理解的进程可以当作是主线程,通过分配自己的代码给它内部的线程,内部的线程拿到数据和代码资源区执行分配给它的工作,从而执行相应的操作;
重谈虚拟地址空间
页表如何映射
计算页表大小
所以一个页表最大为4mb,并且一个页表的二级页表不一定为1024个,因为页表的映射也不是一次就完成的,而已页表的映射使用完之后还会释放等;所以一个页表大小不会大于4mb;
就是这样的页表完成了我们的映射;那我们的数据和代码都是存储在这个地址空间上的;而函数就是一个现成的地址,所以我们分配给线程代码数据,是不是可以直接将这个函数分给线程呢?这样不就等于把线程需要执行的工作划分给了线程吗?
所以线程划分资源本质上是将地址空间中的资源进行分配
为什么我们要创建线程?线程优点
1.同一进程中线程之间的切换更加轻量化;
在我们的内存中最快的是寄存器,,cpu之间拿寄存器中的数据进行计算,寄存器也需要获取数据,而寄存器不是之间从内存中拿数据的,因为内存相较于寄存器还是太慢了,所以它们之间还有一个cache缓存,这个cache中存放的是当前进程的数据和指令,寄存器可以很快的就从cache中拿到一个进程中的数据(cache命中率会很高,因为都在同一进程,都是热数据);因为同一进程中的线程是共享数据的,所以cache切换时只需要切换task_struct,而进程之前切换所有数据都需要切换(进程切换了,进程间具有独立性,cache中的数据一定都需要被切换,所咦数据会变冷重新去命中数据),这样的切换消耗会大的多;
2.创建和销毁线程的代价要小很多;因为线程的数据已经在内存中了,线程只需要从它所在的进程中获取数据即可;
3.io密集型程序,通过多线程可以提高很大的效率,在进行io的时候进程可以让其他线程进行计算等操作,不需要等待io结束再操作;相比单线程的等待要优化非常多;
4.计算密集型程序,在单核cpu中多线程没有什么提升,想法,线程之间的切换还会降低效率;但是在多核cpu中,多线程可以在多个核上进行计算(计算线程数要小于等于核的数量),也是大大提高了计算的效率的;
线程缺点:
由于线程之前没有独立性,共享进程代码数据,代码的健壮性要低一些,所以需要进行同步于互斥;缺乏访问控制->健壮性低;相应的调试也会更难;
线程数据
每个线程虽然都是进程的一部分,从进程中获得数据的,但是线程一定需要包含自己的数据;
线程自己的数据:
1.线程对应的上下文数据(寄存器)
2.线程运行时数据(独立的栈空间)
3.线程id
4.信号屏蔽字
5.调度优先级
6.errno
线程操作
上面讲解了线程的基本内容,下面我们来对线程进行操作来理解线程;
我们需要先了解这些linux中posix标准中的原生线程库中的函数;
线程创建
pthread_create
这个函数是用来创建子线程的;
第一个参数是一个输出型参数,用来输出创建线程的tid;
第二个参数是用来设置线程的属性的,其实这是一个指向线程属性对象的指针,通过传递我们设置好的对象传递给线程从而改变线程的默认属性,一般我们都传递NULL使用默认属性即可;
第三个参数是一个回调函数,用来提供给线程运行的代码,可以理解为让线程执行此函数;
第四个参数就是一个传递给函数(第三个参数——回调函数)的参数,这个参数既可以是普通的内置类型,也可以是结构体,这样可以很多的数据;
返回值返回0为成功创建,创建失败返回返回错误码,不设置errno;
下面可以看到我们的代码成功运行了;
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
using namespace std;
void *routine(void *data)
{
for (int i = 0; i < 5; i++)
{
usleep(100000);
cout << "线程1, pid: " << getpid() << endl;
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, routine, nullptr);
for (int i = 0; i < 7; i++)
{
usleep(200000);
cout << "线程0, pid: " << getpid() << endl;
}
return 0;
}
从上面的现象我们可以清楚的知道线程是一个独立的执行流虽然routine函数和main函数它们两个再同一个程序中且是两个循环但是,这两个循环同时跑起来了,所以证明了线程的独立性;
编译时需要加-lpthread选项
在linux中使用原生线程库进行编程时我们编译选项总是需要带上-lpthread,这个选项在我们前面学习动静态库的时候就很熟悉了,用来连接指定的库;而似乎我们在以往的编程中除开我们自己创建动静态库的情况之外,我们从未出现过主动连接动静态库的情况;
为什么我们不需要主动去连接呢?这是因为编译器自动去帮我们连接了,我们的c,c++语言级别的库也好,linux的系统库也罢,它们库的路径都是已经存储在编译器的配置文件中的,编译器可以自动的找到库(第一步),然后编译器会自动连接这些库(第二步);为什么会自动连接呢?我们可以认为这些系统库和标准库是编译器自己的库,所以编译器会自动的连接;而pthread这个库是posix标准中的原生线程库;它是属于第三方库的,而第三方库即使它被放到系统,标准库的路径之下,它也是不会被自动连接的;所以我们需要带上-lpthread选项去主动连接这个库;
查看线程
我们看到的线程的现象接下来,我们从系统的角度的入手,使用系统的指令来查看我们的线程的体现;
ps -aL
lwp的全称是light weight process轻量级进程;
线程的等待与tid获取函数
pthread_join
子进程被创建,父进程需要等待进程返回,而线程被创建也需要被等待,但是这里只有主线程和其他线程的区别,主线程需要等待其他所有线程,防止内存泄漏的问题;
这里的第一个参数是指向被等待线程的tid;
第二个参数是一个输出型参数可以用来接收线程的返回值,这个返回值可以是任意类型的数据(自定义类型也可以);
返回值为0代表等待成功,非0则返回错误值,不设置errno码;
pthread_self
可以获得线程的tid;
这是一个无参函数和getpid的使用方式是一样的;
代码实现
知道了这些基本的函数后,我们下面用代码实践来展示现象并解释:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
using namespace std;
struct thread_data
{
string threadName;
string threadReturn;
};
void *routine(void *data)
{
thread_data *d1 = static_cast<thread_data *>(data);
// thread_data *d1 = (thread_data *)(data);
int count = 3;
for (int i = 0; i < count; i++)
{
printf("tid: %p threadName: %s count: %d\n", pthread_self(), d1->threadName.c_str(), i);
sleep(1);
}
// int a=5/0;//除0错误 这里说明了当进程中的某个线程出现异常时,整个进程都会退出
// exit(0);//使用exit退出 这里也说明了使用exit会退出整个进程
d1->threadReturn="return_"+d1->threadName;
return d1;
}
void initThread(thread_data *data, int num)
{
data->threadName = "thread_" + to_string(num);
}
int main()
{
pthread_t tid;
thread_data *data = new thread_data;
initThread(data, 1);
int ret_create = pthread_create(&tid, nullptr, routine, (void *)data);
void *ret_thread;
printf("我是主线程tid: %p\n",pthread_self());
pthread_join(tid, &ret_thread);
cout << ((thread_data *)ret_thread)->threadReturn << endl;//证明获得了一个类返回值
delete data;
return 0;
}
使用return正常退出的情况:
下面是使用exit和异常退出的情况:
通过代码和现象我们可以知道这些细节:
1. 我们可以使用join获取线程的返回值,线程返回值可以为任意类型的指针,所以可以传递任意值;
2.我们的子线程退出的时候不能使用exit退出这样会导致整个进程都退出,我们可以使用return,pthread_exit(后面讲),使用cacel取消joined(后面讲),这3种方式退出;
3.进程中的任意一个线程出现异常整个进程都会退出
4.线程的tid是一个地址,这个地址是进程堆栈之间的内存区域(通过上面的现象也可清楚的明白)
由此我们可以知道这些函数的大致使用;
线程结构体位置
上面我们通过概念与实现基本的了解了线程,接下来我们通过图像来了解线程的结构体:
其实我们的线程是这样存在在我们的进程中的,因为linux程序员为了减轻代码的维护效率linux中没有真正的线程,而是将线程作为轻量级进程,而用来描述轻量级进程的结构体是存储在用户层的,存储的位置就是共享区的原生线程库,线程库中维护了线程的属性数据,内核的执行流(tcb控制块)通过找到进程中的线程库中的线程结构体从而找到线程代码执行线程;
所以线程的属性是由线程库来维护的,而tid之所以是共享区之中的代码的原因就是因为tid指的是共享区中线程库中的某个线程结构体所在的首地址;
线程空间的特点
1.线程之间的栈空间是独立的;
这一点非常好理解,因为函数在被调用的时候就会创建自己的栈帧嘛;而线程执行其实就是执行了分给他的函数;所以线程栈空间是独立的;
2.线程之间是没有秘密的;
为什么线程之间独立但是又没有秘密呢?因为线程总是在一个进程中的嘛,栈之间的数据,只需要通过一个指针就可以获得了;
代码示例:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <vector>
#include <string>
using namespace std;
struct threadData
{
string threadName;
threadData(int num)
{
threadName = "thread" + to_string(num);
}
threadData() = default;
};
int *g_index;
void *routine(void *args)
{
int val = 0;
threadData *data = (threadData *)args;
for (int i = 1; i <=3 ; i++)
{
printf("%s tid: %p val: %d\n", data->threadName.c_str(), pthread_self(), val);
val++;
}
if(data->threadName=="thread1")
{
val=10000;
g_index=&val;
sleep(5);
}
return (void *)0;
}
int main()
{
vector<pthread_t> tids;
for (int i = 0; i < 3; i++)
{
pthread_t tid;
threadData *td = new threadData(i);
pthread_create(&tid, nullptr, routine, td);
tids.push_back(tid);
sleep(1);
}
cout<<"这是thread2的val值: "<<*g_index<<endl;
for(auto t:tids)
{
void *retData;
pthread_join(t,&retData);
}
return 0;
}
但是如果我们想要获得某个栈空间的数据时这也是可以轻松做到的:
我们在routine函数中加入一段这样的代码,并在main函数中读取数据;
routine函数中:
if(data->threadName=="thread1")
{
val=10000;
g_index=&val;
sleep(5);
}
main函数中:
cout<<"这是thread2的val值: "<<*g_index<<endl;
线程的变量:__thread选项
int *g_index;
//int g_val;
__thread int g_val;
void *routine(void *args)
{
int val = 0;
threadData *data = (threadData *)args;
for (int i = 1; i <=3 ; i++)
{
//printf("%s tid: %p val: %d\n", data->threadName.c_str(), pthread_self(), val);
//val++;
printf("%s g_val: %d\n",data->threadName.c_str(),g_val);
g_val++;
}
// if(data->threadName=="thread1")
// {
// val=10000;
// g_index=&val;
// sleep(5);
// }
return (void *)0;
}
int main()
{
vector<pthread_t> tids;
for (int i = 0; i < 3; i++)
{
pthread_t tid;
threadData *td = new threadData(i);
pthread_create(&tid, nullptr, routine, td);
tids.push_back(tid);
sleep(1);
}
//cout<<"这是thread2的val值: "<<*g_index<<endl;
for(auto t:tids)
{
void *retData;
pthread_join(t,&retData);
}
return 0;
}
我们线程在使用 g_val全局变量时:
g_val带上__thread编译选项时:
__thread是编译选择,不是c,c++的语法是编译器的选项;
特点:
1.将进程全局数据变为线程全局数据
2.只能给内置类型带上这个选项
C++线程库说明
在我们的C++中是有语言级别的线程库的(C语言没有),C++中的线程库是跨平台的,但是我们在使用C++线程库时,我们还是会发现,我们需要带上编译选项-lpthread所以说明C++的线程库是封装了原生线程库的,而原生线程库在linux中是posix标准的,在windows中又有不同的标准;但是C++的线程库是跨平台的,所以说明C++的线程库不仅封装了linux的posix标准线程库还封装了windows下的线程库;
clone系统调用的封装
我们前面说线程是轻量级的进程,为什么这么说呢?其实我们在创建线程时使用的pthread_create函数和创建子进程的fork函数都是封装了clone的系统调用;
int clone(int (*fn)(void *), void *child_stack
, int flags, void *arg
, ... /* pid_t *ptid, void *tls, pid_t *ctid */);
这个系统系统调用会指定一片栈空间给新开辟的线程,我们不需要懂clone调用的细节,我们只需要知道,linux中其实在底层上线程的接口也是和进程用的一样的调用,所以它们在内核层面上是处于同一级别的执行流的,所以线程被称为轻量级进程;
小提示:
线程如何使用进程替换的调用会将当前的整个进程替换掉
线程终止
前面我们说了线程的3个正常退出方式;我们下面来详细的讲解一下:
pthread_exit
这个函数就是和return一样的作用,返回一个retval给主线程;这里需要注意的是retval最好是堆上的指针,线程终止栈帧也会销毁,会导致栈上的数据被释放,所以返回值一定要是不被释放的数据;
pthread_cancel
这是一个线程终止函数,我们可以通过此函数终止掉tid的线程:
这里终止了就不需要再join了,如果join了会发返回非0值;
这是gpt给出的提示:
尽管 pthread_cancel
函数可以请求取消另一个线程,但是线程是否真正被取消,以及何时被取消,是由目标线程自身来决定的。目标线程可以选择忽略取消请求,或者在适当的时机响应取消请求并执行清理操作。
线程分离
pthread_detach
我们的主线程永远是最后退出的,因为需要等待所有创建进程退出,我们常见的服务器一般都是死循环不退出的程序;而当主线程不关系创建的线程的结果时,可以使用detach来断开创建线程与主线程之间的关系;,主线程就不需要等待子线程了;
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include<cstring>
using namespace std;
void* routine(void*args)
{
cout<<"我是被创建线程"<<endl;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,routine,nullptr);
void* ret;
pthread_detach(tid);
int ret_join=pthread_join(tid,&ret);
printf("%s\n",strerror(ret_join));
return 0;
}
当没有detach时:
当创建的线程被detach时
所以说明线程不能被同时detach和join;
此外线程可以自己detach自己;
以上就是线程的控制与基本概念,线程部分未完待续;