【Linux】线程控制

文章目录

    • 线程的概念
      • Linux下的进程
      • Linux下的线程
      • 进程再理解
      • Linux线程和接口的认识
      • 代码验证
      • 二级页表
    • 页表
    • 线程的优点
    • 线程的缺点
      • 线程异常
    • 线程的用途
    • 进程和线程的关系
    • 线程控制
      • 线程
      • 线程ID和LWP
      • 线程等待
      • 线程终止
      • 线程分离
    • 线程ID及进程地址空间布局

线程的概念

我们知道,进程在各自独立的地址空间中运行,进程之间共享数据需要用mmap或者进程间通信机制,本节我们学习如何在一个进程的地址空间中执行多个线程。有些情况需要在一个进程中同时执行多个控制流程,这时候线程就派上了用场,比如实现一个图形界面的下载软件,一方面需要和用户交互,等待和处理用户的鼠标键盘事件,另一方面又需要同时下载多个文件,等待和处理从多个网络主机发来的数据,这些任务都需要一个“等待-处理”的循环,可以用多线程实现,一个线程专门负责与用户交互,另外几个线程每个线程负责和一个网络主机通信。

以前我们讲过,main函数和信号处理函数是同一个进程地址空间中的多个控制流程,多线程也是如此,但是比信号处理函数更加灵活,信号处理函数的控制流程只是在信号递达时产生,在处理完信号之后就结束,而多线程的控制流程可以长期并存,操作系统会在各线程之间调度和切换,就像在多个进程之间调度和切换一样。

Linux下的进程

进程:PCB+mm_struct+页表+MMU+物理内存

先将代码加载到虚拟内存,然后通过预加载对代码进行一点点地映射到物理内存。

【Linux】线程控制_第1张图片

Linux下的线程

Linux当中其实没有线程这一概念,我们把轻量级进程叫做线程,线程是运行在进程当中的执行流,一个进程最少要有一个执行流,线程也是CPU调度的最小单位。

如下为线程的简略示意图:

【Linux】线程控制_第2张图片

在Linux当中,没有创建线程专属的结构体,而是对进程PCB进行稍作修改,也就是轻量级进程。

各线程共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式(SIG_IGNSIG_DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

但如下资源是每个线程各有一份的:

  • 线程id
  • 上下文,包括各种寄存器的值、程序计数器和栈指针
  • 栈空间
  • errno变量
  • 信号屏蔽字
  • 调度优先级

进程再理解

曾经说进程就是一个task_struct+进程地址空间+页表+MMU+物理内存,这么说也没错,因为进程至少有一个执行流,当执行流为1的时候就成立。

但现在又知道,一个进程当中会存在多个执行流,因此,如下一整块才被称之为进程。

【Linux】线程控制_第3张图片

进程是分配系统资源的实体。

线程是CPU调度的最小单位。

Linux线程和接口的认识

在Linux中线程是用进程模拟实现的 所以说Linux中不会给我们提供线程的操作接口 (这里解释下 其实Linux不是没有能力去提供这些操作接口 而是它想要保持一个相对自由的状态给用户) 而是给我们提供了一个在同一个进程地址空间中创建PCB的方法 分配给资源指定的PCB

但是作为一个用户来说 使用这种方法的学习成本太高了 我们更需要一个完整的线程库

所以说一些应用级的开发工程师就在应用层对于轻量级的Linux接口进行封装成为了我们经常使用的原生线程库

代码验证

#include 
 
int pthread_create(pthread_t *thread,
    const pthread_attr_t *restrict attr,
    void *(*start_routine)(void*), void *restrict arg);

Compile and link with -pthread.

以下是各个参数的具体含义:

  1. thread:指向新创建的线程标识符的指针。线程创建后,其标识符将被存储在此指针所指向的位置。
  2. attr:指向线程属性的对象。若不关心线程的属性,可以将其设为NULL。
  3. start_routine:指向新线程的入口点函数的指针。此函数必须是静态链接的,且不能返回任何值。
  4. arg:传递给新线程入口点函数的参数。这个参数可以为空。

注意:pthread_create函数返回一个整型值,表示线程的创建状态。如果线程创建成功,它将返回0;如果出现错误,它将返回一个非零值。

其它线程可以调用pthread_join得到start_routine的返回值,类似于父进程调用wait(2)得到子进程的退出状态。

pthread_create成功返回后,新创建的线程的id被填写到thread参数所指向的内存单元。我们知道进程id的类型是pid_t,每个进程的id在整个系统中是唯一的,调用getpid()可以获得当前进程的id,是一个正整数值。线程id的类型是thread_t,它只在当前进程中保证是唯一的,在不同的系统中thread_t这个类型有不同的实现,它可能是一个整数值,也可能是一个结构体,也可能是一个地址,所以不能简单地当成整数用printf打印,调用pthread_self()可以获得当前线程的id。

#include 
#include 
#include 

int num = 0;

using namespace std;

void *test(void *args)
{
    while (1)
    {
        cout << "线程ID:" << pthread_self() << " "<< "num:" << num++ << endl;
        sleep(1);
    }
}

int main(void)
{
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, test, (void *)"hello");
    pthread_create(&tid2, NULL, test, (void *)"hello");
    sleep(1);
    cout << "tid1:" << tid1 << endl;
    cout << "tid2:" << tid2 << endl;
    while (1)
    {
        sleep(1);
    }

    return 0;
}

【Linux】线程控制_第4张图片

可以看到,这里定义的全局变量num并没有像多线程一样发生修改的时候就会重新映射页表,使得进程之间的资源独立,而是多个线程之间共享了这个num

#include 
#include 
#include 

//int num = 0;

using namespace std;

void *test(void *args)
{
    int num=0;
    while (1)
    {
        cout << "线程ID:" << pthread_self() << " "<< "num:" << num++ << endl;
        sleep(1);
    }
}

int main(void)
{
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, test, (void *)"hello");
    pthread_create(&tid2, NULL, test, (void *)"hello");
    sleep(1);
    cout << "tid1:" << tid1 << endl;
    cout << "tid2:" << tid2 << endl;
    while (1)
    {
        sleep(1);
    }

    return 0;
}

【Linux】线程控制_第5张图片

但如果定义在函数体内部,可以发现num就不共享了,这是由于线程都有自己的栈空间,线程也有自己的私有数据。

我们可以通过ps -aL命令来查看线程。

【Linux】线程控制_第6张图片

PID代表的是Process ID(进程ID),LWP代表的是Light Weight Process(轻量级进程)。这两个概念并不相同,但在一些场景下有所关联。

  1. PID:PID是指系统中唯一的标识一个进程的一个数字,通常用来标识某个具体的进程。当创建了一个新的进程时,系统会给它分配一个唯一的PID。
  2. LWP:LWP是指轻量级进程,它与普通进程相比具有较低的开销。通常来说,一个普通的进程会有若干个LWP组成,每个LWP都负责执行某一部分代码或任务。

ps -aL命令中,PID表示的是整个进程的标识,而LWP表示的是进程中的某个具体的执行实体。例如,如果一个进程有多个LWP,那么在ps -aL命令中,每个LWP都会有一个自己的PIDLWP ID,以此来区分不同LWP之间的执行状态。

这里的PID:20486PIDLWP怎么是一样的呢?

因为这个是主线程,另外两个就是我们创建的子线程。

二级页表

在讲二级页表之前先说一下一级页表。

假如我们没有页表,所有的内存都是段式访问,而段式内存访问有一个缺点:

【Linux】线程控制_第7张图片

我们的进程C需要11M的内存,但是由于空闲区域F1和F2都不能满足进程C需要的内存,同时由于进程A和B都是活跃进程,因此不可以被腾出,于是进程C必须等待进程A或进程B腾出相应的内存空间,但是这种等待是不可控的。

F1和F2两块区域的和是足够进程C的内存,但由于我们为进程C分配的内存是连续的,因此这种段式内存的利用率是低下的。

为了解决这种分段模式下进程的线性地址等同于物理地址问题,我们必须要将线性地址和物理地址解绑,解绑以后线性地址连续,但物理地址可以不连续。

这就需要借助分页机制。

页表

(MIT6.S081)页表

线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多
  • 与进程之间的切换相比 线程之间的切换需要操作系统做的工作要少很多
  • 线程占用的资源要比进程少很多
  • 能充分利用多处理器的可并行数量
  • 在等待慢速IO操作结束的同时 程序可执行其他的计算任务
  • 计算密集型应用 为了能在多处理器系统上运行 将计算分解到多个线程中实现
  • IO密集型应用 为了提高性能 将IO操作重叠 线程可以同时等待不同的IO操作
  • 计算密集型:执行流的大部分任务,主要以计算为主。比如加密解密、大数据查找等。
  • IO密集型:执行流的大部分任务,主要以IO为主。比如刷磁盘、访问数据库、访问网络等。

线程的缺点

  • 性能损失: 一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器 如果计算密集型线程的数量比可用的处理器多 那么可能会有较大的性能损失 这里的性能损失指的是增加了额外的同步和调度开销 而可用的资源不变

假设有一个单核处理器上的程序,其中有三个计算密集型任务A、B和C。如果这三个任务同时运行,则它们都需要等待处理器空闲出来才能继续运行。如果任务A正在运行,则任务B和C都需要等待任务A完成才能继续运行,反之亦然。这就是所谓的同步和调度开销。 为了减少这种情况带来的开销,可以将任务A、B和C分别放在不同的处理器上运行。这样,它们就可以并行地执行,从而减少了同步和调度开销。但是,如果只有一个处理器,则这种方法无效。 因此,为了获得最佳的性能,应根据实际情况确定最适合的线程数量。如果任务是计算密集型的,则线程数等于可用的处理器数量;如果是I/O密集型的任务,则线程数应小于可用的处理器数量。健壮性降低: 编写多线程需要更全面更深入的考虑 在一个多线程程序里 因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的 换句话说 线程之间是缺乏保护的。

  • 缺乏访问控制: 进程是访问控制的基本粒度 在一个线程中调用某些OS函数会对整个进程造成影响
  • 编程难度提高: 编写与调试一个多线程程序比单线程程序困难得多

线程异常

  • 如果某一个线程出现除零错误、野指针等问题,整个进程都会崩溃
  • 线程是进程的执行分支,线程出现了异常,就类似进程出现异常,进而触发信号,整个进程都会出错。

线程的用途

  • 合理的使用多线程 能提高CPU密集型程序的执行效率
  • 合理的使用多线程 能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具 就是多线程运行的一种表现)

进程和线程的关系

【Linux】线程控制_第8张图片

线程控制

线程

#include 
 
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);
  • 返回值:成功返回0,失败返回错误号,以前学过的系统函数都是成功返回0,失败返回-1,错误号会保存在全局变量errno当中,而pthread库的函数都是通过返回值返回错误号,虽然每个线程都有一个errno,但这是为了兼容其它函数接口提供的,pthread库本身并不使用它,通过返回值返回错误码更加清晰。
  • thread:是一个输出型参数,创建成功的线程ID会写入这个变量当中。
  • attr:用于设置线程的特殊属性,NULL表示使用默认的线程属性。
  • start_routine:创建线程后要执行的函数。
  • arg:函数的参数。

在一个线程中调用了pthread_create()创建新的线程后,当前线程从pthread_create()返回继续往下执行,而新的线程所执行的代码由我们传递给pthread_create()的函数指针start_routine决定,start_routine会接受一个参数,就是arg,参数类型为void*,这个指针具体是什么类型由调用者自己决定,void*可以接受任何类型的指针,通过强制类型转换可以转化为自己想要的类型。start_routine的返回类型也是void*,这个指针的含义同样是由调用者自己定义。当start_routine函数返回的时候,这个线程就退出了。其他线程可以调用pthread_join()来获取start_routine的返回值,就类似于父进程可以调用wait()得到子进程的退出状态。

下面我们让主线程调用pthread_create函数创建一个新线程,此后新线程就会跑去执行自己的新例程,而主线程则继续执行后续代码。

#include 
#include 
#include 
#include

using namespace std;

void *test(void *args)
{
    int num=0;
    while (1)
    {
        cout << "线程ID:" << pthread_self() << " "<< "num:" << num++ << endl;
        sleep(1);
    }
}

int main(void)
{
    pthread_t tid1;
    int err=pthread_create(&tid1, NULL, test, (void *)"hello");
    if(err!=0)
    {
        fprintf(stderr, "can't create thread: %s\n", strerror(err));
        exit(1);
    }
    sleep(1);
    while (1)
    {
        cout<<"我是主线程,线程ID:"<<pthread_self()<<endl;
        sleep(2);
    }
    return 0;
}

【Linux】线程控制_第9张图片

运行代码后可以看到,新线程每隔一秒执行一次打印操作,而主线程每隔两秒执行一次打印操作。

while :; do ps axj | head -1 ; ps axj |grep pthread | grep -v grep ; echo "------------------------------------------------------------
---"; sleep 1 ; done; echo "-------------------------------------------------

通过如上命令可以查看进程,可以看到始终只有一个进程

【Linux】线程控制_第10张图片

ps -aL

通过如上命令可以查看线程,可以看到有两个线程,一个主线程,一个子线程。

【Linux】线程控制_第11张图片

线程ID和LWP

【Linux】线程控制_第12张图片

【Linux】线程控制_第13张图片

pthread_self()拿到的是用户级的线程ID,而LWP是内核级的线程ID,类似于文件描述符和inode之间的关系。

线程等待

#include 
 
int pthread_join(pthread_t thread, void **value_ptr);
  • thread:被等待的线程ID(用户级)。
  • value_ptr:线程退出时的退出码信息。
  • 如果thread线程通过return返回,value_ptr所指向的单元里存放的是thread线程函数的返回值。
  • 如果thread线程被别的线程调用pthread_cancel异常终止掉,value_ptr所指向的单元里存放的是常数PTHREAD_CANCELED
  • 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。

如果对thread线程的终止状态不感兴趣,可以传NULLvalue_ptr参数。

#include 
#include 
#include 
#include 
#include
#include
#include 
using namespace std;

struct info
{
    string name;
    int return_val;
};

void *thr_fn1(void *arg)
{
    printf("thread 1 returning\n");
    return (void*)1;
}
 
void *thr_fn2(void *arg)
{
    printf("thread 2 exiting\n");
    pthread_exit((void *)2);
}
 
void *thr_fn3(void *arg)
{
    while(1) {
        printf("thread 3 writing\n");
        sleep(1);
    }
}
 
int main(void)
{
    pthread_t   tid;
    void* tret;
 
    pthread_create(&tid, NULL, thr_fn1, NULL);
    pthread_join(tid, &tret);
    printf("thread 1 exit code %d\n", (int*)tret);
 
    pthread_create(&tid, NULL, thr_fn2, NULL);
    pthread_join(tid, &tret);
    printf("thread 2 exit code %d\n", (int*)tret);
 
    pthread_create(&tid, NULL, thr_fn3, NULL);
    sleep(3);
    pthread_cancel(tid);
    pthread_join(tid, &tret);
    printf("thread 3 exit code %d\n", (int*)tret);
 
    return 0;
}

【Linux】线程控制_第14张图片

可以看到,通过返回值返回的线程其tret都是返回的值,而是由pthread_cancel()取消线程的,返回值都是PTHREAD_CANCELED的整形。

在Linux的pthread库中常数PTHREAD_CANCELED的值是-1。可以在头文件pthread.h中找到它的定义:

#define PTHREAD_CANCELED ((void *) -1)
#include 
#include 
#include 
#include 
#include
#include
#include 
using namespace std;

struct info
{
    string name;
    int return_val;
};

void *thr_fn1(void *arg)
{
    printf("thread 1 returning\n");
    return (void*)1;
}
 
void *thr_fn2(void *arg)
{
    printf("thread 2 exiting\n");
    exit(-1);
}
 
void *thr_fn3(void *arg)
{
    while(1) {
        printf("thread 3 writing\n");
        sleep(1);
    }
}
 
int main(void)
{
    pthread_t   tid;
    void* tret;
 
    pthread_create(&tid, NULL, thr_fn1, NULL);
    pthread_join(tid, &tret);
    printf("thread 1 exit code %d\n", (int*)tret);
 
    pthread_create(&tid, NULL, thr_fn2, NULL);
    pthread_join(tid, &tret);
    printf("thread 2 exit code %d\n", (int*)tret);
 
    pthread_create(&tid, NULL, thr_fn3, NULL);
    sleep(3);
    pthread_cancel(tid);
    pthread_join(tid, &tret);
    printf("thread 3 exit code %d\n", (int*)tret);
 
    return 0;
}

【Linux】线程控制_第15张图片

将代码稍作修改,如果我先终止线程2,那么整个进程都会退出,线程3也不会被执行了。

线程终止

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

  • 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit
  • 一个线程可以调用pthread_cancel终止同一进程中的另一个线程。
  • 线程可以调用pthread_exit终止自己。
#include 
 
void pthread_exit(void *value_ptr);

value_ptrvoid *类型,和线程函数返回值的用法一样,其它线程可以调用pthread_join获得这个指针。

需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

线程分离

一般情况下,线程终止之后,其终止状态会一直保持到其他线程调用pthread_join来获取线程的终止状态。但如果线程被detach(分离)了,一旦线程之中之后直接会被操作系统回收掉。

不能对一个已经处于detach状态的线程调用pthread_join,这样的调用将返回EINVAL。对一个尚未detach的线程调用pthread_joinpthread_detach都可以把该线程置为detach状态,也就是说,不能对同一线程调用两次pthread_join,或者如果已经对一个线程调用了pthread_detach就不能再调用pthread_join了。

#include 

int pthread_detach(pthread_t thread);
#include 
#include 
#include 
#include 
 
void *thr_fn1(void *arg)
{
    pthread_detach(pthread_self());
    printf("thread 1 returning\n");
    sleep(3);
    return (void *)1;
}
 
int main(void)
{
    pthread_t tid;
    void* tret;
 
    pthread_create(&tid, NULL, thr_fn1, NULL);
    pthread_join(tid, &tret);
    printf("thread 1 exit code %d\n", (int*)tret);
    while(1)
    {
        
    }
    return 0;
}

【Linux】线程控制_第16张图片

线程ID及进程地址空间布局

线程之间到底共享了哪些资源?

线程之间到底哪些资源是私有的?

我们知道,一个程序会经历预处理、编译、汇编、链接这四个过程,而链接就是在链接动静态库。

【Linux】线程控制_第17张图片

【Linux】线程控制_第18张图片

也就是说线程会共享动态库中的所有代码。

【Linux】线程控制_第19张图片

一个进程的虚拟地址空间一般可以大致划分为代码区(text)、只读数据区(rodata)、初始化数据区(data)、为初始化数据区(bss)、堆(heap)、共享内存区(.so,mmap的地方)、栈(stack)、内核区(kernel)。

【Linux】线程控制_第20张图片

对于 Linux 进程或者说主线程,其 stack 是在 fork 的时候生成的,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝 (cow) 以及动态增长
然而对于主线程生成的子线程而言,其 stack 将不再是这样的了,而是事先固定下来的。
线程栈不能动态增长,一旦用尽就没了,这是和生成进程的 fork 不同的地方。

线程(非主线程)的栈的大小是固定的,其会在空闲的堆(堆顶附近自顶向下分配)或者是空闲栈(栈底附近自底向上分配),因此线程栈局部函数中分配的变量是存放到各自分配的栈空间,因此可以说是线程私有的,又因为该线程栈的边界是设定好的,线程栈之间有以小块guardsize用来隔离保护各自的栈空间,一旦另一个线程踏入到这个隔离区,就会引发段错误,因此该线程栈的大小的固定的。

主线程从进程栈分配空间,大小并不是固定的,如果分配空间大于进程栈空间,那么直接运行时出现段错误。

通过ulimit -a可以看到,栈的大小是8192kb,也就是8M

【Linux】线程控制_第21张图片

从 heap 的顶部向下分配。
ps -ajax| grep pthread 查看 pid
cat /proc/[pid]/maps 这个显示进程映射了的内存区域和访问权限。
可以看到:在 heap 下面连续的几个属性为 rw-p 的地址大小刚好都为 8192kb。并且每个都在边界穿插了一个大小为 1000H(4096kb) 的边界空间。

【Linux】线程控制_第22张图片

从 stack 底部向上分配
ulimit -s unlimited 设置 stack size 为 unlimited,注意虽然设置了stack size为无限,但是实际上其并不是无限的,而也是固定大小的线程栈,大小为1mb。
然后 cat /proc/[pid]/maps 查看虚拟地址空间的映射。
可以看到,这种情况下线程栈是分配在 stack 底附近,自底向上生长的。
【Linux】线程控制_第23张图片

【Linux】线程控制_第24张图片

当每增加一个线程,就会在栈或者堆区创建一个结构体,这个结构体来源于动态库。

【Linux】线程控制_第25张图片

对于主线程的代码或者变量,所有线程都是共享的。

void thread(void* var) {
    int* p = (int*)var;
    *p = 2;
}
 
 
int main() {
    int a = 1;
    pthread_t tid;
    
    pthread_create(&tid, NULL, thread, (void*)&a);
    return 0;
}

【Linux】线程控制_第26张图片

而对于线程的私有栈,数据是不共享的。

你可能感兴趣的:(Linux,linux,服务器,运维,c++,c语言,算法)