Linux《线程(上)》

发布于:2025-09-15 ⋅ 阅读:(16) ⋅ 点赞:(0)

通过之前的学习我们已经了解了操作系统当中的基本的概念包括进程、基础IO、磁盘文件存储等,但是到目前为止我们还未了解到线程相关的概念,这就使得当前我们对操作系统的认知还不是完整的,现在我们是还是无法理解一个进程当中是如何同时的执行多个任务的。接下来在本篇当中我们就将来了解到线程的相关概念,包括线程在我们的理解上应该是什么样的,还有线程在操作系统当中具体是如何实现的,以及具体的在我们使用的Linux当中是如何使用的,还会将线程对比我们之前学习的进程,最后再来了解一些线程控制相关的接口,相信通过本篇的学习能让你对线程有基本的认知,接下来就开始本篇的学习吧!!!



1.线程概念

1.1 什么是线程

实际上从教材当中解释的线程和之前我们学习过的进程的概念如下所示:

  • 进程:操作系统分配资源(内存、文件描述符等)的基本单位。

  • 线程:进程中的“执行单元”,它负责具体运行代码。

但是目前的问题是我们还没有了解过什么是执行流,那么这时我们就试着不从教程中这些晦涩的文字区理解欸,而是转为从实际Linux当中区理解试试看。

通过之前进程的学习知道进程=内核数据结构+代码和数据,每个进程都会有其对应的虚拟地址空间和进行映射的页表,本质上进程的task_struct就是通过虚拟地址空间来“看到”实际上的物理内存,这时就可以将地址空间看作是一个“窗口”。

那么通过以上的理论就可以说明之前我们创建的进程都是让一个task_struct享有所有的mm_struct,那么会不会出现一种情况就是进程当中同时拥有多个task_struct,这时不就可以让多个task_struct共享进程当中的虚拟地址空间了吗。

其实以上的设想就是Linux当中实现线程的基本逻辑,本质上在Linux当中是没有真正的线程的,本质上是使用进程来模拟实现进程,我们将这种用于模拟线程的进程称为“轻量级进程”,在一个进程当中是会可能出现多个task_struct的,本质上一个task_struct就可以代表一个线程,只不过在之前的学习当中使用到的进程都是只有一个tesk_struct的,这时就表示该进程当中是只有一个线程的。

通过以上的说明,那么你其实就可以将执行流简单的先理解为线程,一个进程当中可以存在多个线程就是可以同时存在多个执行流,相比进程线程创建、销毁的成本更小,从内核当中就可以理解为线程在销毁的时候只需要销毁对应的PCB即可,但是进程则需要将虚拟地址空间和页表也进行销毁。

但实际上线程是不完全等同与执行流的,实际上 线程=执行流+必要的运行环境

注:轻量级进程这种概念实际上只是在Linux当中才有的,其他的操作系统当中正常是没有的,因为例如Windows这些操作系统实现线程的方式是在内核当中实现真正的线程。

这时候你可能就会思考,为什么在Linux当中要使用这种方法来实现线程呢?为什么不在内核当中实现真正的线程的数据结构对象呢?
这个问题其实很好理解,Linux其实也可以像Windows一样在内核当中实现真正的线程,在Windows当中有专门的数据结构对象TCB,而在Linux当中使用PCB来模拟实现线程是因为使用PCB就意味着线程的内核代码是可以复用PCB的代码的,那么这时就可以让线程基于原来的代码实现。

通过以上的理解我们就可以得出以下的结论:
1.在Linux当中实际上线程是由轻量级进程来模拟的。
2. 在进程当中线程对于资源的划分本质上就是对地址空间范围的划分。而虚拟地址空间就是资源的代表。
3.在进程当中的虚拟地址空间对于线程来说是如何进行划分的呢?实际上划分的操是不需要用户来执行的,我们知道不同的函数时有不同的入口地址,那么这时候不同的线程实际上就会指向不同虚拟地址空间。

通过以上的理解我们就可以发现实际上在操作系统学科当中和实际上的操作系统是有一定的区别的,在操作系统当中只是提供了基本的思想,而在具体的操作系统当中是按照操作系统给定的基本思想实现的,但不同的实现的思想可能不同。

实际上感性上的理解进程和线程可以将进程理解为一个家庭,而线程是家庭当中的一个成员,那么进行社会资源分配的时候的基本实体就是家庭,而家庭当中的成员又可以具体的划分是在干什么。
因此
在操作系统当中进程是进行资源分配的基本单位,而线程是系统当中进行调度的最小单位。

1.2 分页式存储

在之前的学习当中我们了解过在进程当中是通过页表将虚拟地址空间二号物理内存完成两个之间的映射关系的,有了页表就可以将实际数据存储的从物理内存当中的无序变成了虚拟地址空间上的有序。这样在进程的视角当中就可以认为数据的存储是连续的。

但是目前的问题是实际上虚拟地址和物理地址是如何进行映射的呢?

如果是按照之前的了解其实是虚拟地址空间当中的每天一个虚拟地址都和实际内存当中的一个物理地址进行映射,那么这时就会出现在下x86的系统当中,页表当中每个物理地址和虚拟地址进行映射的一条页表就会在内存当中占用8字节,假设虚拟地址空间的大小是4GB,这时将所有虚拟地址和物理地址进行映射页表的最终大小就为32GB,那么这种情况下在操作系统载入之后就要占用内存当中的32GB,这时不就非常的不合理吗?
因此实际上在操作系统当中是肯定无法使用一个单独的页表来进行虚拟地址和物理内存的映射。因此实际上的页表是通过多级完成映射的。

大致的结构如下所示:

首先会有一个页表当中存储虚拟地址当中的前部分的地址内容,例如虚拟地址的长度为32,那么页表当中存储的就是虚拟地址前10位,那么这时就会有2^10=1024个表项,接下来再将虚拟地址当中的后10位的地址再作为页表的当中的地址,那么在这种设计的情况下就可以使得每个页表目录当中的地址都能指向一个大小为1024的空间。
在之前文件的存储学习当中我们就了解到了,在磁盘当中内存的加载都是按照4kb的大小为基本的单位的,实际上在内存当中进行内存的加载也是按照4kb的单位进行的,这就说明即使实际程序申请内存的大小为4097B,那么在内存当中实际申请的大小也是8kb。那么这就说明在内存当中进行数据管理也是按照4kb进行管理的,在此就将每个4kb的物理空间成为页框。那么在操作系统当中就需要将这些页框进行管理,,这样操作系统就知道哪些页框是使用的,哪些页框是空闲的,内核当中实现了以下所示的结构体来表示每个物理页。

/* include/linux/mm_types.h */
struct page
{
    /* 原⼦标志,有些情况下会异步更新 */
    unsigned long flags;
    union
    {
        struct
        {
            /* 换出⻚列表,例如由zone->lru_lock保护的active_list */
            struct list_head lru;
            /* 如果最低为为0,则指向inode
             * address_space,或为NULL
             * 如果⻚映射为匿名内存,最低为置位
             * ⽽且该指针指向anon_vma对象
             */
            struct address_space *mapping;
            /* 在映射内的偏移量 */
            pgoff_t index;
            /*
             * 由映射私有,不透明数据
             * 如果设置了PagePrivate,通常⽤于buffer_heads
             * 如果设置了PageSwapCache,则⽤于swp_entry_t
             * 如果设置了PG_buddy,则⽤于表⽰伙伴系统中的阶
             */
            unsigned long private;
        };
        struct
        { /* slab, slob and slub */
            union
            {
                struct list_head slab_list; /* uses lru */
                struct
                { /* Partial pages */
                    struct page *next;
#ifdef CONFIG_64BIT
                    int pages;    /* Nr of pages left */
                    int pobjects; /* Approximate count */
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            struct kmem_cache *slab_cache; /* not slob */
            /* Double-word boundary */
            void *freelist; /* first free object */
            union
            {
                void *s_mem;            /* slab: first object */
                unsigned long counters; /* SLUB */
                struct
                {                        /* SLUB */
                    unsigned inuse : 16; /* ⽤于SLUB分配器:对象的数⽬ */
                    unsigned objects : 15;
                    unsigned frozen : 1;
                };
            }
        };
        ...
    };
    union
    {
        /* 内存管理⼦系统中映射的⻚表项计数,⽤于表⽰⻚是否已经映射,还⽤于限制逆向映射搜
        索*/
        atomic_t _mapcount;
        unsigned int page_type;
        unsigned int active; /* SLAB */
        int units;           /* SLOB */
    };
    ...
#if defined(WANT_PAGE_VIRTUAL)
        /* 内核虚拟地址(如果没有映射则为NULL,即⾼端内存) */
        void *virtual;
#endif /* WANT_PAGE_VIRTUAL */
    ...
}

有了以上的struct page结构体之后就可以在内核当中创建struct page的数组,这样就内核就可以通过对该数组的管理来完成对实际物理内存的管理。那么这级相比直接对物理内存进行管理的代价小的多了。

所以总的来说Linux 内核里的 struct page 是连接“物理内存页帧”与“内核/用户内存管理机制”之间的桥梁

所以实际上在页表当中每个元素当中存储的也是物理内存当中每个页框的地址,那么在要X86当中从虚拟地址定位到物理地址时候最后就只需要将后12位当作该页框当作的偏移量,这样要访问对于的就只需要得到对于都要访问地址的的页框地址再加上+页内偏移即可。

因此总结来说就可以将虚拟地址映射到物理地址上需要进行以下的两步操作:
1.先找到对于的虚拟地址对应的页框
2.在x86的条件下,之后再将虚拟地址当作的后12位置作为页内偏移量,访问具体的字节

实际上在之前了解的写实拷贝缺页中断等都需要从新建立虚拟地址空间和物理内存之间的映射关系。

1.3 线程周边

以上我们了解到了实际上在操作系统当中页表是通过多级的方式来建立虚拟地址和物理内存之间的映射关系的,这样实现的方式是能减少页表在内存当中的占用空间,但是事物都是有两面性的,多级页表映射的方式虽然确实能减少页表在内存当中的占用但是也就存在相比直接进行映射的时候查找的效率较低的问题,所以在操作系统当中就TBL来减缓多级页表在查询是效率就低的问题,实现的逻辑如下所示:
 

当中CUP得到对应的虚拟地址之后首先就会将该地址发给MMU,这时MMU就不会像之前一样直接的在多级页表当中进行查询而是先从TLB当中查询有没有直接可以得到该虚拟地址映射的物理地址,这样就能提高虚拟地址到物理地址之间转换的效率。实际上TLB的本质就是一小块的缓存,会将访问频率较高的虚拟地址和物理地址之间的映射保存下来

一句话进行总结TLB的作用就是:缓存最近常用的虚拟页到物理页的映射,让 CPU 在访问内存时大多数情况下不必再通过多级页表查找,从而大幅提升效率

如果我们在TLB和页表当中都无法找到对应的映射关系话,这时CPU就会给自己发送缺页异常从而触发缺页中断,那么这时CPU得到对应的中断号接下来在操作系统当中的中断向量表中得到中断处理方法,而这个中断处理方法的工作就是建立对应的虚拟地址和物理地址之间的页表映射关系。

1.4 线程优点以及缺点

线程的优点如下所示

1.创建和切换开销小,创建线程比创建进程轻量

线程切换不需要更换虚拟内存空间 → 避免了 TLB 刷新 和大量缓存失效。资源消耗少

同一进程内的线程共享代码段、数据段、文件等资源。相比进程更节省系统资源。

2.并行能力强

在多处理器系统上,线程可以并行运行,提高计算效率。

3.提升 I/O 性能

I/O 等待时,其他线程还能继续执行,避免 CPU 空闲。

多线程可以并发等待多个 I/O,提高吞吐量。

线程的缺点如下所示:

1.性能损失

多线程需要额外的调度和同步开销。

当线程数多于处理器数时,计算密集型线程之间会争抢 CPU,效率可能反而下降。

2.健壮性降低

线程共享内存空间,彼此缺乏隔离。

小的时间差或错误的共享变量使用,容易导致难以发现的 bug(如竞态条件、死锁)。

3.缺乏访问控制

进程是操作系统的最小保护单位,而线程不是。

一个线程的错误操作可能影响整个进程,导致全部线程出问题。

4.编程难度高

需要处理同步、互斥、死锁等复杂问题。

调试和维护难度远高于单线程程序。

通过以上我们就能发现线程的优点很大,但缺点也并不是“小问题”它要求开发者必须非常谨慎地设计和实现,否则很容易出现严重问题。

1.5 线程 VS 进程

通过以上的学习再结合之前进程的学习我们就知道了进程是资源分配的基本单位,线程是调度的基本单位。那么除此之外线程相比进程还有哪些的区别呢?

实际上同一个进程当中的所有线程是共用同一份的虚拟地址空间的,那么这时候就可以使得在同一个进程当中的线程在进行切换的时候依然能将原来的页表和TLB和页表继续的保存下来,而在进程当中因为不同的进程都有自己独立的一份虚拟地址空间,那么这时候就会造成进程切换的时候会将对应的TLB和页表进行更新。

但线程除了有公共的部分还有独立的部分,就例如线程有自己独立的寄存器,用于保存线程的上下文数据;该背后就说明线程是被独立调度的。除此之外线程还有独立的,那么这就说明线程是一个动态的概念。

本质上通过以下的图示就可以解答线程和进程之间的关系:

2. 初识Linux线程控制

2.1 POSIX线程库

在此接下来我们要进行了解的线程库是POSIX线程库,本质上POSIX 线程库(Portable Operating System Interface for uniX threads,简称 Pthreads)是遵循 POSIX 标准的一套 多线程编程接口。主要在类 Unix 系统(Linux、macOS、BSD 等)上使用,用 C 语言接口实现。

实际上除了以上的POSIX线程库之外还存在其他的线程库就例如语言当中提供的线程库,例如在C++11当中提供对应的线程库。这些库底层一般还是调用操作系统的线程 API(Pthreads 或 Win32 threads),但对开发者更友好。

注:在此C++11当中线程库的使用会等到在C++当中进行学习,不过建议将Linux当中的线程学习之后再去学习C++的线程API,那么这时理解起来会很简单。

2.1 了解线程控制接口

以上我们已经了解到上面是POSIX库,那么接下来就来了解该库当中提供的线程创建相关的接口。

1.线程创建

功能:创建⼀个新的线程
原型:
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;失败返回错误码

通过以上的描述就可以看出该函数是需要通过用户传入对应的线程ID以及线程对应对的属性,还有线程运行起来之后需要执行的方法。该函数在创建线程成功的时候就会返回0,否则就会返回对应的错误码。

接下来来看一下的代码来看看使用以上的接口进行线程创建是什么样的:
 

#include<iostream>
#include<pthread.h>
#include<unistd.h>

void* routine(void* args)
{
    while(1)
    {
        const char* s=static_cast<const char*>(args);
        std::cout<<"我是线程:"<<s<<" 正在运行"<<std::endl;
        sleep(1);
    }

}



int main()
{

    pthread_t t1;
    pthread_create(&t1,nullptr,routine,(void*)"pthread-1");

    while(1);


    return 0;
}

以上代码当中使用的对应的pthread_create来创建线程,在创建当中给该线程传一个字符串的参数,那么在线程运行起来的时候就会执行routine方法,在该方法当中就可以通过函数的参数得到对应的字符串,只不过在获得的过程当中需要将对应的指针进行强制类型转换。

此时再实现以下的makefile文件之后接下来编译以上的代码:

Main:thread.cc
	g++ -o $@ $^ -lpthread
.PHONY:clean
clean:
	rm -f Main

注:在此在使用g++的时候需要带上-lpthread选项,那么程序在执行的时候才能找到对应的pthread库。
 

以上程序运行的结果如下所示:

以上我们是让main函数当中的主要进程只是运行而不输出内容,如果该线程也向显示器当中进行打印会出现什么样的结果呢?

将代码main函数修改为以下的形式:

这时输出结果就会变为以下的形式:

这时就会发现两个线程输出的内容混杂在了一起,这实际上是我们未进行线程同步的原因,要解决这个我问题就需要了解线程同步之后的概念。

在之前进程的学习当中我知道使用ps指令能查看当中操作系统当中的进程,那么使用什么样的指令能查看操作系统当中的线程呢?

实际上还是使用ps指令,只不过当前带的选项是-L,例如以的程序在运行的时候使用 ps -aL 指令就能看到以下的结果。

那么这时就可以看到两个线程对应的PID是一样的,但是这里又存在问题了,那就是以上当中的LWP又是什么呢?

实际上LWP就是light weight process 就是对应的轻量级进程,实际上我们也可以将其称为线程id。

实际上和之前我们学习进程的调度类似,在线程当中也是会给不同的线程分配对应的时间片,那么这时候CPU就可以根据对应的时间片来进行线程的调度。

以上我们就了解到了pthread库当中提供的线程创建接口pthread_create接口,那么这时候我们就要思考了为什么在此要实现一个pthread的库,让用户直接调用对应的系统调用来进行线程的操作不就可以了吗?

实际上在Linux操作系统当中确实提供了对应的线程调用接口,但是问题是在Linux当中线程操作的系统调用是和其他操作系统当中不同的,那么如果是使用系统调用来进行操作的话,那么我每在一个不同的操作系统当中就需要学习不同的系统调用,那么这对用户来说压力是很大的。因此为了解决该问题pthread在不同的操作系统当中就就将不同的系统调用进行封装,实现给用户统一的接口,那么这时就能让用户的进行线程的操作在不同的系统当中都是一样的。

在Linux普通的使用用户就不再需要LWP等轻量级进程的概念,也就不再需要了解到Linux当中是没有真正的线程的。

实际上pthread库的实现是在用户层当中的,所以将其称为用户级线程。

实际上在Linux当中是实现了对应的系统调用来实现来实现线程的创建的,系统调用的形式如下所示:

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

pid_t vfork(void);

vfork和之前我们学习的 fork的区别就是超级出来的子进程是和父进程公用同一个地址空间的,那么这时不就相当于创建了一个线程。

但是以上vfork本质还是封装了以下的clone函数

 #include <sched.h>

 int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
             /* pid_t *parent_tid, void *tls, pid_t *child_tid */ );

实际上除了以上的pthread库之外在C++11当中也提供了对应的线程库来实现线程创建的功能。

例如以下的示例:

#include <iostream>
#include <thread>
#include<unistd.h>


void hello()
{
    while (1)
    {
        std::cout << "我是子线程,正在运行" << std::endl;
        sleep(1);
    }
}

int main()
{
    std::thread t(hello);

    while (1)
    {
        std::cout << "我是主线程,正在运行" << std::endl;
        sleep(1);
    }

    return 0;
}

那么接下来就需要思考为什么在语言当中也要实现对应的线程库呢,其实这就要讲到语言都是为了提高跨平台的,提高语言的可移植性,那么如果一个用户在Linux当中先进行开发之后接下来需要在Window当中进行开发,那么如果C++当中没有提供对应的接口之后那么这时就无法进行跨平台
。那么是如何实现的呢?实际上一般来说解决的方法就是将所有要兼容的平台的代码都编写一份之后再进行条件编译。

本质上实际上C++11当中提供的线程库在Linux当中是封装了pthread库。

2. 线程等待

实际上和之前我们学习进程类似,在线程当中在进行线程的创建之后也是会申请出对应的空间的,只不过相比进程线程申请的资源比较少,但是如果不进行线程的等待还是会出现内存泄漏的问题的,在pthread库当中提供了以下的接口。

 #include <pthread.h>

 int pthread_join(pthread_t thread, void **retval);

参数:
thread:要等待的线程 ID(pthread_create 返回的)。
retval:用于存放目标线程的返回值指针(如果线程用 pthread_exit 返回值,这里能拿到;如果不需要返回值,可以传 NULL)。

返回值:
成功返回 0
失败返回错误码(如 ESRCH:线程不存在,EINVAL:线程不可被 join 等)。

那么在了解了以上线程等待的接口之后接下来就可以使用以上的接口对上面实现的代码进行线程的等待。

实现的代码如下所示:

#include<iostream>
#include<pthread.h>
#include<unistd.h>

void* routine(void* args)
{
    while(1)
    {
        const char* s=static_cast<const char*>(args);
        std::cout<<"我是线程:"<<s<<" 正在运行"<<std::endl;
        sleep(1);
    }
}


int main()
{
    pthread_t t1;
    pthread_create(&t1,nullptr,routine,(void*)"pthread-1");
    while(1)
    {
        std::cout<<"我是主线程,真正运行当中……"<<std::endl;
        sleep(1);
    };

    pthread_join(t1,nullptr);

    return 0;
}

以上就是本篇的全部内容了,接下来在线程概念《下》当中将继续学习线程的概念,未完待续……