1.一个进程有自己对应的PCB、对应的地址空间、页表(虚拟地址映射到物理地址)、物理内存。
以前的认知:进程 = 内核数据结构 + 进程对应的代码和数据
相信上面这张图大家一定不陌生了,原本我们创建进程时,OS需要给我们task_struct, 对应的虚拟地址空间,页表,物理内存。
当我们创建一个子进程时,我们同样需要task_struct, 对应的虚拟地址空间和页表。
由于进程具有独立性,进程间通信是非常麻烦的。
那么什么是线程? 教材里说:线程是进程内的一个执行流。相信大家看到都一脸懵逼,难以理解。
教材的说法太过于宏观,太抽象,难以理解,因此这次讲多线程我将以LInux为准。(不同的操作系统具体的实现会不同)
如何看待虚拟内存:虚拟内存决定了进程能够看到的"资源"。(进程是封闭房子中的人,而虚拟内存是窗户,人只能通过窗户看到外面)
以前创建子进程:PCB 虚拟内存 页表 都搞一份
选择创建线程:只创建PCB,都指向同一个进程地址空间。
我们之前接触的进程内部都只有一个task_struct,即该进程内部只有一个执行流,是单执行流进程。因此内部有多个task_struct的进程有多个执行流,叫做多执行流进程。
现在的进程认知:
首先是只需要创建PCB,消耗的资源大大减少,其二,一个进程内的线程都指向同一块虚拟内存,线程间通信的代价就非常小了。
接下来让我们一起回答三个问题
1.什么叫进程? 内核视角:承担系统分配资源的基本实体
2.在Linux中,什么叫做线程? CPU调度的基本单位。(cpu调度时只关注task_struct)
3.如何看待我们之前学习进程时,对应的进程概念。
进程是承担分配系统资源的基本实体,只不过内部只有一个执行流(以前),现在一个进程内可以有多个执行流。
大致了解线程后,让我们来思考一个问题?
如果我们OS真的要专门设计“线程”概念,OS需不需要来管理这个线程呢?
如何管理? ---------- 先描述再组织, 我们一定需要为线程设计专门的数据结构表示线程对象(TCB),windows采用的就是这种方法,同样因此,Windows的代码实现就显得复杂多了。
执行调度时,我们需要线程的(id、状态、优先级、上下文、栈)。 所以单纯从线程调度的角度,线程和进程有很多的地方是重叠的,所以Linux工程师选择了直接复用PCB,用PCB来表示Linux内部的“线程”。
1Linux内核中没有真正意义上的线程,Linux使用进程PCB来模拟线程的。
2.站在CPU的视角,每一个PCB,都可以称之为轻量级进程。
3.Linux线程是cpu调度的基本单位,而进程是承担分配系统资源的基本单位。
4.进程用来整体申请资源,线程向进程要资源。
5.Linux无法直接提供创建线程的系统调用接口,而只能给我们提供创建轻量级进程的接口。
6.Linux选择复用有什么好处? 简单,维护成本大大降低,更加可靠和高效。
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
编写与调试一个多线程程序比单线程程序困难得多
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
合理的使用多线程,能提高CPU密集型程序的执行效率
合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
进程是资源分配的基本单位
线程是调度的基本单位
线程共享进程数据,但也拥有自己的一部分数据:
线程ID
一组寄存器
栈
errno
信号屏蔽字
调度优先级
因为线程都在同一个地址空间,代码段(Text Segment)、数据段(Data Segment)都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
文件描述符表
每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
当前工作目录
用户id和组id
之前我们学习的进程都是只有一个线程执行流的进程。
Linux可以提供创建轻量级进程的系统调用接口,即创建一个进程,但是共享空间,其中典型的代表之一就是vfork()函数。
vfork()函数的功能是创建子进程,但是父子进程会共享空间。
pid_t vfork(void);
vfork的返回值和fork()的相同:
#include
#include
#include
#include
using namespace std;
int gval = 100; //定义一个全局变量,用来测试vfork后父子进程是否同用一块空间。
int main()
{
pid_t id = vfork();
if(id == 0)
{
gval = 200;
printf("我是子进程 -- pid:%d,ppid:%d,gval:%d\n",getpid(),getppid(),gval);
exit(0);
}
printf("我是父进程 -- pid:%d,ppid:%d,gval:%d\n",getpid(),getppid(),gval);
return 0;
}
可以看到打印出来的gval的值是相同的,父子进程是共享空间的。
在Linux中,从内核角度看,并没有真正意义上线程相关的接口,但是站在用户角度,当用户想创建一个线程时更期望使用thread_create这样类似的接口,而不是vfork函数,因此系统在用户层提供了原生线程库pthread。原生线程库实际就是对轻量级进程的系统调用进行了封装,在用户层模拟实现了一套线程相关的接口。
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的。
要使用这些函数库,要通过引入头文
链接这些线程函数库时要使用编译器命令的“-lpthread”选项,因为原生线程库pthread是动态链接的。
创建一个新的线程的函数叫做pthread_create( ).
//原型
int pthread_create(pthread_t *thread, const pthread_attr_t *attr
, void *(*start_routine)(void*), void *arg);
参数:
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:
错误检查:
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。
pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小
接下来让我们用主线程来创建一个新线程:
#include
#include
#include
#include
using namespace std;
//新线程
void* routine(void* args)
{
char* msg = static_cast(args);
while(true)
{
cout<< msg <<":我是新线程,正在运行!"<
运行后,我们可以看到主线程和新线程交替进行打印,至于其中为什么出现了一行混合在一起的情况在线程安全部分再予以解答。
当我们用ps axj 指令查看当前进程信息,我们只能看到一个进程信息,因为我们的两个线程都属于该进程。
ps axj | head -1 && ps axj | grep mythread | greap -v grep
使用ps -aL 就可以看到轻量级进程了。
LWP(Light Weight Process)就是轻量级进程的ID。
从上图我们可以看到主线程的PID和LWP相同.
注意: 在Linux中,应用层的线程与内核的LWP是一一对应的,实际上操作系统调度的时候采用的是LWP,而并非PID,只不过我们之前接触到的都是单线程进程,其PID和LWP是相等的,所以使用PID和LWP并无区别。
接下来让我们用主线程创建一批新线程:
#include
#include
#include
#include
using namespace std;
//新线程
void* routine(void* args)
{
char* msg = static_cast(args);
while(true)
{
printf("%s: 我是新线程,我的pid:%d,ppid:%d\n",msg,getpid(),getppid());
sleep(1);
}
}
int main()
{
pthread_t tid[5];
for(int i=0 ;i<5;++i)
{
char* buff = new char[64];
snprintf(buff,sizeof(buff),"thread%d",i+1);
int n = pthread_create(tid+i,nullptr,routine,buff);
assert(0==n);
(void)n;
}
//主线程
while(true)
{
printf("我是主线程,我的pid:%d,ppid:%d\n",getpid(),getppid());
sleep(2);
}
return 0;
}
我们成功创建了5个线程,他们的pid和ppid都相同,因为都属于同一个进程。
用ps -aL 命令,我们看到了6个轻量级进程。
pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:
pthread_t pthread_self(void);
获取线程ID的两种方式:1.输出型参数,pthread_create的第一个参数。 2.pthread_self,获取调用该函数的线程的ID.
#include
#include
#include
#include
using namespace std;
//新线程
void* routine(void* args)
{
sleep(1);
char* msg = static_cast(args);
while(true)
{
printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:%lld\n",msg,getpid(),getppid(),pthread_self());
sleep(1);
}
}
int main()
{
pthread_t tid[5];
for(int i=0 ;i<5;++i)
{
char* buff = new char[64];
snprintf(buff,sizeof(buff),"thread%d",i+1);
int n = pthread_create(tid+i,nullptr,routine,buff);
assert(0==n);
(void)n;
}
for(int i=0;i<5;++i)
{
printf("主线程中通过输出型参数存储的tid[%d]:%lld\n",i,tid[i]);
}
//主线程
while(true)
{
printf("我是主线程,我的pid:%d,ppid:%d\n",getpid(),getppid());
sleep(2);
}
return 0;
}
注意: 用pthread_self函数获得的线程ID与内核的LWP的值是不相等的,pthread_self函数获得的是用户级原生线程库的线程ID,而LWP是内核的轻量级进程ID,它们之间是一对一的关系。
pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
tid实际上就是struct_pthread的起始地址.
线程和进程一样,都是需要等待的,如果主线程不对新线程进行等待,那么新线程运行结束时的资源也不会被回收,成了类似僵尸进程一样的情况,导致了内存的泄漏。
线程等待函数:pthread_join( );
int pthread_join(pthread_t thread, void **retval);
参数:
返回值:
调用该函数的线程将挂起等待,直到TID为thread的线程终止,thread线程以不同的方法终止,通过pthread_join得到的退出码是不同的。
总结:
注意点:
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
#include
#include
#include
#include
using namespace std;
//新线程
void* routine(void* args)
{
sleep(1);
char* msg = static_cast(args);
while(true)
{
printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:0X%0x\n",msg,getpid(),getppid(),pthread_self());
sleep(1);
}
}
int main()
{
pthread_t tid[5];
for(int i=0 ;i<5;++i)
{
char* buff = new char[64];
snprintf(buff,sizeof(buff),"thread%d",i+1);
int n = pthread_create(tid+i,nullptr,routine,buff);
assert(0==n);
(void)n;
}
sleep(2);
return 0;
}
在主线程创建完线程后我们直接返回,因为进程内的线程共享空间,所以当主线程返回时OS将对应的资源回收时,其他进程的资源也没了,所以也会退出。
void pthread_exit(void *retval);
注意:
接下来我们将退出码设置为2233
#include
#include
#include
#include
using namespace std;
//新线程
void* routine(void* args)
{
sleep(1);
char* msg = static_cast(args);
while(true)
{
printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:0X%0x\n",msg,getpid(),getppid(),pthread_self());
sleep(1);
pthread_exit((void*)2233);
}
}
int main()
{
pthread_t tid[5];
for(int i=0 ;i<5;++i)
{
char* buff = new char[64];
snprintf(buff,sizeof(buff),"thread%d",i+1);
int n = pthread_create(tid+i,nullptr,routine,buff);
assert(0==n);
(void)n;
}
void* ret;
for(int i =0;i<5;++i)
{
pthread_join(tid[i],&ret);
printf("线程%d :exit_code: %d\n",i,(long long)ret);
}
return 0;
}
注意点:exit()函数是终止整个进程,任意线程调用都会导致进程终止。
线程是可以取消的,我们可以通过其取消一个执行中的线程
int pthread_cancel(pthread_t thread);
参数
返回值:
线程是可以取消自己的,但我们一般不这样子,一般用于一个线程取消另一个线程。比如:主线程取消新线程。
#include
#include
#include
#include
using namespace std;
//新线程
void* routine(void* args)
{
sleep(1);
char* msg = static_cast(args);
while(true)
{
printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:0X%0x\n",msg,getpid(),getppid(),pthread_self());
sleep(1);
pthread_exit((void*)2233);
}
}
int main()
{
pthread_t tid[5];
for(int i=0 ;i<5;++i)
{
char* buff = new char[64];
snprintf(buff,sizeof(buff),"thread%d",i+1);
int n = pthread_create(tid+i,nullptr,routine,buff);
assert(0==n);
(void)n;
}
void* ret;
for(int i=0;i<4;++i)
{
pthread_cancel(tid[i]);
}
for(int i =0;i<5;++i)
{
pthread_join(tid[i],&ret);
printf("线程%d :exit_code: %d\n",i,(long long)ret);
}
return 0;
}
通过pthread_calcel取消的进程退出码都是PTHREAD_CANCELED(-1)。
通过新线程取消主线程会如何呢?
#include
#include
#include
#include
using namespace std;
pthread_t minTID;
//新线程
void* routine(void* args)
{
sleep(1);
char* msg = static_cast(args);
while(true)
{
printf("%s: 我是新线程,我的 pid:%d, ppid:%d, tid:0X%0x\n",msg,getpid(),getppid(),pthread_self());
sleep(1);
pthread_cancel(minTID);
}
}
int main()
{
pthread_t tid[5];
minTID = pthread_self();
for(int i=0 ;i<5;++i)
{
char* buff = new char[64];
snprintf(buff,sizeof(buff),"thread%d",i+1);
int n = pthread_create(tid+i,nullptr,routine,buff);
assert(0==n);
(void)n;
}
void* ret;
for(int i =0;i<5;++i)
{
pthread_join(tid[i],&ret);
printf("线程%d :exit_code: %d\n",i,(long long)ret);
}
return 0;
}
运行代码的同时,我们用监控脚本来进行观察:
$ while :; do ps -aL | head -1 && ps -aL | grep mythread | grep -v grep ; echo "###########" ;sleep 1;done
主线程被取消后右边显示< defunct >
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
int pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
#include
#include
#include
#include
using namespace std;
//新线程
void* routine(void* args)
{
char* msg = static_cast(args);
int count = 3;
while(count>0)
{
printf("%s: 我是新线程,我的pid:%d,ppid:%d\n",msg,getpid(),getppid());
sleep(1);
--count;
}
}
int main()
{
pthread_t tid[5];
for(int i=0 ;i<5;++i)
{
char* buff = new char[64];
snprintf(buff,sizeof(buff),"thread%d",i+1);
int n = pthread_create(tid+i,nullptr,routine,buff);
assert(0==n);
(void)n;
}
for(int i=0;i<5;++i)
{
pthread_detach(tid[i]); //分离线程
}
//主线程
while(true)
{
printf("我是主线程,我的pid:%d,ppid:%d\n",getpid(),getppid());
sleep(2);
}
return 0;
}
新线程退出时,OS直接回收了资源,不再需要主线程join。