Linux系统编程:对于线程概念的理解

目录

一. 预备知识

1.1 OS对于地址空间的细粒度划分管理

1.2 IO的基本单位

1.3 一级页表和二级页表

二. 线程的基本概念

2.1 什么是线程

2.2 Linux环境下对于进程和线程的重新理解

2.3 线程的创建

2.4 线程和进程的特性比较

三. 总结


一. 预备知识

1.1 OS对于地址空间的细粒度划分管理

我们知道,对于每一个正在运行的进程,OS都会为它创建一个PCB,并为其分匹配虚拟的进程地址空间和页表,而进程地址空间中,根据每个虚拟地址处数据类型的不同,又可以被划分为:内核空间、命令行参数和环境变量区、栈区、堆区、共享区、未初始化全局数据区、已初始化全局数据区和常量区。

我们以堆区为例,堆区空间是由用户 malloc / new 动态申请得来的,假设我们执行多次malloc / new,就可以在堆区申请到多块空间,但是,通过 malloc / new 动态申请内存,通过返回值只能获取到申请空间的起始位置,那么怎样知道每一块空间的起始地址和结束地址呢?

换言之,OS对进程地址空间中某个区域(堆区)的管理,是将其视为一个统一的整体来管理,还是将其划分为更小的区块,来进行细粒度的管理?

OS通过内核级数据结构struct vm_area_struct,来实现对进程地址空间的细粒度划分管理。vm_area_struct 中,存有指向前一个和后一个 vm_area_struct 的指针,其中还包括start和end,表示这个vm_area_struct所管理的地址空间的起始地址和结束地址,将每一个vm_area_struct当做一个双链表节点来处理,就可以实现对地址空间的细粒度划分管理。

据此,我们可以设想一下连续malloc在堆区申请5块动态空间,OS做了什么。OS会先后创建5个vm_area_struct对象,每次调用malloc创建空间后,就获得一个vm_area_struct,并将其插入到链表的中去,这样就将每次malloc申请的堆区空间进行了细粒度划分管理。

结论:Linux通过vm_area_struct记录进程地址空间中每个小块空间的属性信息,并通过特定的数据结构对每个vm_area_struct节点进行管理,以实现对进程地址空间的细粒度管理。

图1.1 采用vm_area_struct对进程地址空间进行细粒度管理

1.2 IO的基本单位

编译器对源代码进行编译后,会生成可执行文件,而一旦这个可执行文件被载入到内存之中去运行,就会形成一个进程,这个进程又有属于其自身的进程地址空间。

进程地址空间中的虚拟地址,是由编译器赋予的,而不是进程运行起来后由操作系统分配。每个可执行文件中的数据,都会由编译器以4KB为基本单位来进行划分,同时,OS对物理内存的管理,也是以4KB为单位的。如图1.2所示,可执行程序中的数据,在运行期间会以4KB为单位载入到内存中去,每个叶帧,对应一个页框。

结论:IO的基本单位为4KB。

  • 页帧:可执行程序中每个4KB大小的空间。
  • 页框:物理内存中,每个4KB大小的空间。
图1.2 可执行程序的IO

我们假设物理内存的大小为4G,物理内存按照4KB为单位划分为一个个页框,粗略估算,内存中大概有100W左右个页框。那么,这100W左右的页框是否要被管理起来呢?答案是要的!

Linux提供struct Page来对每个页框进行管理,每个页框都有一个struct Page与之对应,通过一定的内核数据结构,来对所有的页框进行管理。

据此,我们可以推断,可执行程序成为一个进程在CPU中运行时,映射关系的建立流程为:OS为进程分配地址空间和页表 -> 将虚拟地址写入页表中 -> 从磁盘中找数据,载入内存 -> 将载入到内存中的物理地址填入到页表。

如果在通过页表中虚拟地址映射的页面不在物理内存中,即:页表中的虚拟地址还没有和物理地址成功建立映射关系,就会触发缺页中断。缺页中断发生在进程运行起来之前,物理地址载入页表与虚拟地址建立映射的时候,图1.3为缺页中断的处理流程。

触发缺页中断的原因:页表中的虚拟地址没有映射到物理地址。

图1.3 缺页中断的处理流程

1.3 一级页表和二级页表

以32位环境为例,一个进程地址空间中有2^{32}个地址,假设只有一张页表来进行虚拟地址和物理地址之间的映射,就需要2^{32}组映射关系,对于页表中的每组映射,至少要有一个虚拟地址(4bytes)、一个物理地址(4bytes)和一些描述信息,就假设一组映射关系需要8字节,那么2^{32}组映射关系就需要约32G的页表空间,而页表是存储在内存中的,这显然无法实现!

为此,在32位环境下,操作系统给出的解决方案是将每个虚拟地址的32个二进制比特位划分为三组,第1 ~ 10个bit为对应一级页表,第11 ~ 20个比特位对应二级页表,最后12个bit位为偏移量。

用前10个bit位,建立的一级页表,去映射第 11 ~ 20 个bit位建立的二级页表,通过一级页表和二级页表的映射,可以找到物理内存中每个4KB页框的起始地址,最后12个bit位为实际要映射的物理地址相对于页框起始地址的偏移量,这样,通过 一级页表 + 二级页表 + 页内偏移量 的方式,就实现成了虚拟地址到物理地址的映射。

结论:虚拟地址映射到物理地址的方式为 一级页表 + 二级页表 + 页内偏移量

图1.4 虚拟地址到物理地址的映射方法

二. 线程的基本概念

2.1 什么是线程

线程:线程是在进程内部执行的执行流,是OS调度的基本单位。

我们知道,OS会为每一个进程,都创建一个PCB,OS中各个进程都拥有独自的PCB,并且,每个进程都有属于其本身的数据和代码,以及一份地址空间,即使是父子进程之间的进程地址空间也是独立的。

如图2.1所示的场景,几个task_struct共享一份地址空间,分别通过页表,映射执行这个地址空间指向的不同的函数方法,即:通过一定的技术手段,将一个进程地址空间的资源分配给多个task_struct共享,共享同一份进程地址空间资源的task_struct,就是隶属于同一个进程的不同线程。

图2.1 线程调度

也就是说,Linux严格意义上讲并不区分进程和线程,只认PCB。如果几个线程属于同一个进程,那么它们的PCB会指向同一份地址空间,如果两个PCB属于不同的进程,那么他们所使用的的地址空间一定不是同一份。

可以这样理解,线程就是进程中的执行流,多线程环境下,每个线程可以执行进程中的不同方法以实现并发执行,这样就可以提高运行速度。

结论:Linux没有独立的线程数据结构,是通过进程来模拟线程的

采用进程模拟线程是Linux独有的方式,在windows等其他操作系统中,还是会有专门的数据结构,用于描述线程的。

2.2 Linux环境下对于进程和线程的重新理解

根据2.1章节的内容,我跟可以归纳出这些结论:

  1. 线程是在进程内部执行的,线程是操作系统进行调度的基本单位。
  2. 严格意义上讲Linux不具有真正的线程,甚至不严格意义上区分进程和线程,是通过进程来模拟线程实现的。隶属于同一个进程的不同线程,具有独立的task_struct,但是它们共享同一份地址空间和页表。
  3. Linux操作系统在调度时,只关心PCB,不关系是进程还是线程。

在没有涉及到多线程的时候,我们认为一个进程内部只有一个执行流,这样的进程被称为单线程进程,同时,在不涉及多线程时,我们认为 进程 = PCB + 进程的代码和数据。

现在,我们有了多线程概念,知道了一个进程中可能有多个线程在运行,而每个线程都具有属于其自身的PCB,因此,在用户视角,可认为 进程 = 其内部所有线程的PCB + 进程的代码和数据

一个进程内部的线程共享同一份地址空间的资源,而地址空间和页表对于进程来说是独立的,因此,在内核视角,可认为进程是资源分配的基本单位

Linux采用进程PCB来模拟实现线程这种独特的方式,这样我们就可以认为,相对于其它的操作系统,Linux下的进程为轻量级进程。因为,如果一个进程中只有一个线程,那么这个线程的就对应进程地址空间的全部地址,而如果是多线程进程,那么进程中的不同方法可以由不同的线程来并发执行,这样一个线程就对应于进程地址空间的一部分,相对于其它操作系统采用专门的数据结构来管理线程,一个PCB就是对应于全部地址空间而言,Linux下的PCB可以对应较小的地址空间范围,而Linux又不严格意义上区分进程和线程,因此我们可以是Linux下的进程为轻量级进程。

也正是由于Linux采用这种“轻量级进程”的方式,所以Linux本身只会提供创建这种轻量级进程的系统调用接口。如果要创建线程,就需要使用Linux自带的第三方线程库,使用线程库必须包含头文件#include <pthread.h>,并且,在使用gcc/g++编译使用线程库的源代码时,需要选项-pthread。

2.3 线程的创建

线程创建函数:pthread_create

函数原型:int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void* (*start_routine)(void*), void *args);

本文暂时不关心第一个参数和第二个参数,暂时先知道pthread_t为线程id就好,其中start_routine是线程的入口函数地址,args为传入到start_routine函数中的参数。

如代码2.1所示,在一个进程中调用pthread_create创建五个线程,这样算上main函数的执行流,就有6个线程在这个进程中运行,设置RunThread函数为线程入口,在RunThread中输出线程的pid,这样就可以看到,pthread_create创建的线程和原本就存在的主线程pid是相同的,这样就证明了这些线程隶属于同一进程。

结论:在同一进程中运行的线程,它们的pid相同。

代码2.1:调用pthread_create函数创建线程

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>

void *pthreadRun(void *args)
{
    std::cout << (char*)args << ",   pid:" << getpid() << std::endl;
    sleep(1);
}

int main()
{
    pthread_t tid[5];   

    char para[64];   // 线程入口函数参数
    for(int i = 0; i < 5; ++i)
    {
        snprintf(para, 64, "%s %d", "thread ", i);
        pthread_create(tid + i, nullptr, pthreadRun, (void*)para);
        sleep(1);
    }
    
    std::cout << "main thread, pid:" << getpid() << std::endl;

    return 0;
}
图2.2 代码2.1的运行结果

我们对代码2.1进行更改如代码2.2所示,在pthreadRun函数和main函数的结尾都加入while(true)死循环,如图2.3所示,另起一个终端,通过指令 ps axj | head -1 && ps axj | grep mythread.exe | grep -v grep来监视mythread.exe进程,可以看到,虽然有多个线程被创建,也就是说创建了多个PCB,但依旧只能看到一个进程。如果希望看到每一个线程信息,则需要-L选项,-L选项的功能是查看轻量级进程,通过ps -aL | head -1 && ps -aL | grep mythread.exe | grep -v grep指令,就可以看到6个线程了。

其中,LWP的意义是Light Weight Process,轻量级进程pid。

我们可以看到,主线程的LWP和线程的PID是相同的,而通过pthread_create创建的线程,它们的LWP和进程的PID则是不同的。 

代码2.2:用于监视多线程PID和LWP的代码

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>

void *pthreadRun(void *args)
{
    std::cout << (char*)args << ",   pid:" << getpid() << std::endl;
    sleep(1);
    while(true);
}

int main()
{
    pthread_t tid[5];   

    char para[64];   // 线程入口函数参数
    for(int i = 0; i < 5; ++i)
    {
        snprintf(para, 64, "%s %d", "thread ", i);
        pthread_create(tid + i, nullptr, pthreadRun, (void*)para);
        sleep(1);
    }
    
    std::cout << "main thread, pid:" << getpid() << std::endl;
    while(true);

    return 0;
}
图2.3 对代码2.2的运行和监视结果

三.  线程的特性

3.1 多线程间共享和独有的资源 

多线程共享的资源:

  • 同一地址空间的全局的数据、变量等资源。
  • 文件描述符表fd_array。
  • 对于信号的处理方法(SIG_DFL、SIG_IG、user_handler)。
  • 当前工作目录,用户id和组id。

每个线程独有的资源:

  • 栈区 -- 执行pthreadRun函数需要为它在栈区开一块空间,这块空间独属于pthreadRun函数。
  • 一组寄存器 -- 记录线程的上下文数据。
  • 轻量级进程(线程)id -- LWP
  • 错误码 errno
  • 信号屏蔽字
  • 调度优先级

3.2 线程切换和进程切换的效率比较

  • 线程切换的效率要高于进程切换,原因在于以下两个方面。
  • 线程切换不需要切换地址空间和页表,但是由于地址PCB中指向地址空间的指针mm_struct*和页表MMU,都是存放在CPU寄存器中的,而CPU寄存器效率极高,因此,这并不是线程切换效率高于进程切换的主要原因。
  • 主要原因:CPU中有 1 ~ 3 级cache,根据局部性原理,访问物理内存某个位置处的数据时,会将其周围的数据也同时加载到CPU的cache中,这样就可以减少对物理内存的访问,提高运行效率。在线程切换时,由于共享同一地址空间中的资源,切换后很有可能就不需要更新cache中的数据,而进程切换切换后cache几乎不可能再次命中,那么就一定要切换cache中的数据,造成进程切换的效率低于线程切换。 

3.3 线程相比于进程的优缺点 

线程的优点:

  • 创建一个新线程的成本要低于创建一个新进程。
  • 一个线程占用的资源要小于一个进程占用的资源。
  • CPU切换线程的效率要高于切换进程的效率。
  • 能够充分利用多处理器进行并行处理,提高效率。
  • 在计算密集型和IO密集型的应用场景中,多线程并行计算或IO,可以提高效率。

线程的缺点:

  • 在某些时候降低效率:如果创建的线程过多,那么线程之间频繁的切换,就会消耗成本,造成整体的效率下降。创建线程数目的共识为:线程数量 = CPU核数
  • 降低健壮性:在多线程程序中,由于时间分配的细微差异或者错误的资源共享,容易造成线程之间难以预期的问题,且代码的调试难度大。
  • 缺乏访问控制:进程之间是相互独立的,而线程之间并不独立,一个线程的某些计算操作,可能会对整个进程中的所有线程产生影响。
  • 编程难度大:编写出安全的多线程代码比单线程困难的多,调试难度也更大。

四. 总结

  • OS通过vm_area_struct实现对进程地址空间资源的细粒度划分管理。
  • IO的基本单位是4KB,如果某个虚拟地址无法在页表中找到与之对应的物理地址,那么就会触发缺页中断。
  • 虚拟地址到物理地址,是通过 一级页表 + 二级页表 + 页内偏移量 来映射的。
  • 线程是在进程内部运行的,线程是OS调度的基本单位。
  • Linux不从严格意义上区分进程和线程,其采用PCB的来模拟线程,如果多个PCB共同使用一份地址空间资源,那么,这些PCB就属于同一进程。
  • Linux调度其实不关注调度的是进程还是线程,只关心PCB。
  • 通过pthread_create,可以创建线程,多线程中每个线程有独立的LWP,但它们的pid相同。
  • 同一进程中的每个线程,有一些资源是共享的,有一些资源是每个线程独占的,线程切换的效率要高于进程切换。
  • 虽然线程能够充分利用多处理器并行运算提高效率,但是,多线程也存在代码健壮性低、缺乏访问控制等缺陷。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值