【Linux】线程控制

attribute 属性    argument 参数   start_routine 常规   来自 pthread_create

Linux 多线程

Linux 线程概念

在一个程序中一个执行线路叫做线程(thread)。 更为准确的定义是:线程是“一个进程内部的控制序列”,一个进程内部可以拥有多个线程。并且线程在进程内部运行,本质上是在进程的地址空间中运行。

在Linux系统中,CPU看到的PCB(进程控制块)都要比传统的进程更加轻量化。操作系统可以透过虚拟的进程地址空间,看到进程内部的大部分资源,从而将进程资源合理分给各种执行流,就形成了线程执行流

创建进程 : 创建进程控制块(task_struct) 、进程地址空间(mm_struct) 以及页表,虚拟地址和物理地址通过页表来进行映射。每个进程都有自己独立的进程地址空间和页表,也就意味着进程在运行时本身就具有独立性

如果一个进程已经存在,我们再创建一个"进程",我们只为这个进程创建task_struct, 并要求创建出来的task_struct 和父task_struct共用进程地址空间和页表,这就是所谓的线程。每一个线程就是当前进程中的一个执行流即线程时进程内部的一个执行分支

同时我们可以看出,线程在进程内部运行,本质是线程在进程地址空间中运行,也就是说曾经这个进程申请的所有资源,几乎都被所有线程共享

如何理解进程、线程

通过上述描述我们知道线程就是一个task_struct, 但是进程除了包含一个或者多个task_struct之外,一个进程还需要右进程地址空间、文件控制块、信号位图、页表等等等,这些合并起来叫做一个线程

站在内核的角度:进程是承担分配资源的基本实体,创建进程需要创建进程控制块、创建地址空间、维护页表、并在物理内存中开辟空间建立映射,打开进程默认打开的相关文件、注册信号等处理方案

站在CPU的角度:线程是CPU调度的基本实体,无法识别当前调度的task_struct是否是进程,一个CPU只关心一个个独立的执行流,所以无论进程内部有一个或者多个执行流,一个CPU都是按照一个task_struct为单位进行调度的

Linux 下不存在真正的多线程,Linux系统内的线程是使用进程模拟的

一个进程内至少存在一个线程,所以线程的数量是多于进程的,线程的执行力度和资源划分比进程要细好多。如果操作系统想要支持线程,就需要建立创建、终止、调度、切换、分配资源、回收资源、释放资源等等线程接口,这一套接口相比进程来说都需要另起炉灶,搭建一套与进程平行的更为复杂的线程管理模块。因此如果真要支持线程一定会提高设计操作系统的复杂程度。

所以Linux设计者并没有重新为线程设计数据结构,而是直接复用了进程控制块,所以我们说Linux中所有执行流都叫做轻量级进程。既然Linux没有真正意义上的线程,那么也就绝对没有真正意义上的线程相关的系统调用,Linux提供了创建轻量级进程的接口,也就是创建进程,共享空间,其中最具代表性的就是vfork()函数

vfork() : 创建子进程,但是与父子进程共享空间

pid_t vfork(void);  // 使用方法类似于fork
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
using namespace std;

int main(){
  int a = 10;
  pid_t child_thread = vfork();
  if (child_thread == 0) {
    a = 20;
    cout << "child say : a = " << a << endl;
    exit(0);
  }
  sleep(3);
  cout << "father say : a = " << a << endl;
  return 0;
}

可以看到,父进程读取到的a是子进程修改后的值,证明了vfork()创建的子进程于父进程是共享地址空间的

原生Pthread 库简介

在Linux中,站在内核角度并没有真正意义上的线程相关接口,但是站在用户角度,当用户想要创建一个线程时更希望使用thread_create()类似接口(指向性明确),而不是类似于vfork()这样的函数(需要了解底层原理),因此系统给用户层提供了原生线程库pthread

原生线程库实际就是对轻量级进程的系统调用进行了封装,在用户层模拟实现了一套线程相关接口,对于我们用户来说,学习线程实际就是在使用这一套封装后的接口,而并非操作系统提供的系统调用

理解多级页表

4G内存的机器中,如果页表就是单纯的一张表存储虚拟和物理内存之间的映射关系,那么这张表就需要建立2 ^ 32 个虚拟地址和物理地址之间的关系,就有2 ^ 32个映射项,那么就需要使用2 ^ 32 * 2 个指针也就是 2 ^ 32 * 2 * 4个字节(32G)来存储这张表。并且每张表项中除了存储虚拟地址对应的物理地址外,实际还要存储一些权限相关的信息(用户级页表和内核级页表,就是通过页表中的权限标志位进行区分的)。那么页表的大小还得继续增加,4G内存的机器根本无法存储32G甚至更大的页表。

所以在32位平台下,页表的映射过程并非直接映射:

1、选择虚拟地址的前十个比特位在页目录中进行查找,找到对应的页表

2、再选取虚拟地址的次十个比特位在对应页表中进行查找,找到物理内存中对应页框的起始位置

3、最后将虚拟地址的剩下12个比特位作为偏移量从对应页框的起始地址向后进行偏移,找到物理内存中某一个对应的字节数据

物理内存实际是被划分成,2 ^ 12 字节也就是 4KB大小的页框,磁盘上的程序也是被划分成4KB大小的页帧的,当磁盘进行数据交互也就是按照4KB大小进行加载和保存的。所以最终我们使用了1张一级页表和2 ^ 10 张二级页表,设每一条表项10字节,那么只需要使用 (2 ^ 10 + 1) * 2 ^ 10 * 10 差不多10MB就可以将所有页表加载到内存中了

上面所说的所有映射过程,都是由MMU(MemoryManagementUnit 内存管理单元)这个硬件来完成的,该硬件被集成在了CPU中。页表是一种软件映射,而MMU是一种硬件映射。所以计算机进行虚拟地址到物理地址的转化采用的是软硬结合的方式

解释常量字符串为什么会发生段错误

当我们想要修改一个常量字符串时,虚拟地址必须通过页表映射找到对应的物理内存,而在查表的过程中发现用户给的地址处于常量区,这个区域的页表权限是只读的,此时如果想要对其进行修改就会在MMU内部触发硬件错误,操作系统在识别是哪一个进程导致的之后就会向该进程发送信号另起终止

线程的优缺点

优点

  • 创建一个新线程的代价要比创建一个新进程的代价小得多 (占用资源少)
  • 相对于进程切换,线程切换操作系统需要做的工作非常少 (切换速度快)
  • 能充分利用多处理器的可并行数量
  • 在等待慢速IO操作的同时,程序可以执行其它的计算任务 (节约空闲时间)
  • 计算密集型应用(加密解密、大数据查找),为了能在多处理器系统上运行,将计算分解到多个线程中实现
  • IO密集型应用(刷新磁盘、访问数据库、访问网络),为了提高性能,将IO操作进行重叠,线程可以同时等待不同的IO操作

缺点

  • 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法和其它线程共同分享一个处理器。如果计算密集型线程数量大于CPU数量,那么线程可能会造成较大的性能损失(增加了额外的同步和调度开销,一个CPU上多个计算任务线程来回切换,还不如将任务并在一起减少调度开销)
  • 健壮性降低:编写多线程需要全面深入的考虑代码结构,在一个多线程的程序中,因为时间分配的细微差异或者因为共享了不该共享的变量数据造成不良影响的概率是非常大的,线程之间大部分数据是透明的,线程之间是缺乏保护的
  • 缺乏访问控制:进程是访问控制的基本单位,在一个线程中调用某些系统函数会对整个进程造成影响,一个线程出错就可能会导致整个进程被杀死。如果单个进程出现异常导致进程终止,那么所有线程都会退出

合理使用多线程技术可以提高CPU密集型程序的执行小v了

合理使用多线程技术可以提高IO密集型程序用户的体验(一边看电影一边下载电影,就是多线程运行的一种体现)

进程 VS 线程

进程是承担分配资源的实体,线程是CPU调度的基本单位

线程之间虽然共享大部分数据(Text Segment 代码端,Data Segment 数据段),如果我们定义一个函数,各个线程都可以使用,如果定义一个全局变量,那么所有线程都可以访问。除此之外,各个线程还共享以下资源和环境,文件描述符表(一个线程打开了文件,其它线程也可以看到),每种信号的处理方式,当前工作目录,当前工作目录,用户ID和组ID

但是任有少部分数据独享:线程ID,一组寄存器(存储上下文信息),栈(存储临时数据),errno (C语言提供的全局变量,每个线程都有自己的), pending位图,信号屏蔽字,调度优先级是每个线程私有的

Linux 线程控制

Pthread 线程库

pthread线程库是应用层原生线程库,应用层指的是这个线程库并非系统调用接口提供的,而是第三方为我们提供的,原生指的是大部分Linux系统都会默认帮我们安装好该线程库

# 库的头文件在 /ust/include/pthread.h
[clx@VM-20-6-centos include]$ pwd
/usr/include
[clx@VM-20-6-centos include]$ ls | grep pthread.h 
pthread.h

# 库在 /usr/lib64/libpthread.so 
[clx@VM-20-6-centos lib64]$ ls | grep pthread
libevent_pthreads-2.0.so.5
libevent_pthreads-2.0.so.5.1.9
libgpgme-pthread.so.11
libgpgme-pthread.so.11.8.1
libpthread-2.17.so
libpthread.a
libpthread_nonshared.a
libpthread.so
libpthread.so.0
[clx@VM-20-6-centos lib64]$ pwd 
/usr/lib64

与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以pthread_打头的,在编译阶段需要链接线程函数库

Pthread 线程库的错误检查
  • 传统的函数是成功返回0,失败返回-1,并且对全局变量erron赋值以指示错误
  • pthread系列函数出错时并不会设置全局变量(大部分POSIX函数会这样做),pthread系列函数会将错误代码通过返回值返回
  • pthread同样提供了线程内的errno变量,以支持其它使用errno的代码,对于pthread函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量开销要更小
线程创建
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);

  • thread : 输出型参数,用于获取创建成功线程的ID,该参数是一个输出型参数

  • attr(attribute 属性) : 用于设置创建线程的属性,传入NULL设置默认属性

  • start_routine(routine 常规) : 该参数是一个函数指针,即线程启动后需要执行的函数

  • arg (argument 参数) : 传给线程的参数

  • 线程创建成功返回0,失败返回错误码

主线程创建一个新线程

当一个程序启动时,一个进程被操作系统进行创建,与此同时一个线程也立刻运行,这个第一个被创建的线程就是主线程。

即主线程就是产生其它子线程的线程,通常主线程必须最后完成某些执行操作,比如各种关闭动作

小实验

void* Routine(void* arg) {
  char* msg = (char*)arg;
  while (1) {
    printf("I'm child %s, my pid = %d, my father pid = %d\n", msg, getpid(), getppid());
    sleep(2);
  }
  return nullptr;
}


void pthread_create_test2(){
  pthread_t tids[5];
  for (int i = 0; i < 5; i++) {
    char* buffer = (char*)malloc(64);
    sprintf(buffer, "thread %d", i);     // 输出文字到buffer中
    pthread_create(tids + i, NULL, Routine, (char*)buffer);
  }
  while (1) {
    printf("I'm main thread, my pid = %d, myppid = %d\n", getpid(), getppid());
    sleep(2);
  }
}

I'm main thread, my pid = 22681, myppid = 13628
I'm child thread 0, my pid = 22681, my father pid = 13628
I'm child thread 1, my pid = 22681, my father pid = 13628
I'm child thread 2, my pid = 22681, my father pid = 13628
I'm child thread 4, my pid = 22681, my father pid = 13628
I'm child thread 3, my pid = 22681, my father pid = 13628

[clx@VM-20-6-centos ~]$ ps -axj | head -1 && ps -axj | grep clxtest | grep -v grep
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
13628 22681 22681 13628 pts/11   22681 Sl+   1001   0:00 ./clxtest
[clx@VM-20-6-centos ~]$ ps -aL | head -1 && ps -aL | grep clxtest | grep -v grep
  PID   LWP TTY          TIME CMD
22681 22681 pts/11   00:00:00 clxtest  //可以观察到不同线程LWP是不同的(Light Weight Process)轻量级进程ID
22681 22682 pts/11   00:00:00 clxtest
22681 22683 pts/11   00:00:00 clxtest
22681 22684 pts/11   00:00:00 clxtest
22681 22685 pts/11   00:00:00 clxtest
22681 22686 pts/11   00:00:00 clxtest

通过实验可以看到,我们的线程都是属于同一个进程的,ps -axj命令可以查看当前进程信息,ps -aL命令可以显示当前轻量级进程。默认情况下不带L看到的就是全部进程,而带L就是查看进程内多个轻量级进程

注意:Linux中,应用层线程和内核LWP是一一对应的,实际操作系统调度的时候采用的是LWP而并非PID,单线程进程中LWP和PID起始是相等的,所以对于单线程进程来说,调度的PID和LWP是相同的

获取线程LWP

方法1 : 创建线程时使用输出型参数获得 方法2 : 在线程内部调用pthread_self() 函数

I'm main thread, I created child thread 0, child thread tid = 140234376075008  # 使用tid打印
I'm main thread, I created child thread 1, child thread tid = 140234367682304
I'm main thread, I created child thread 2, child thread tid = 140234359289600
I'm main thread, I created child thread 3, child thread tid = 140234350896896
I'm main thread, I created child thread 4, child thread tid = 140234342504192
I'm main thread, my pid = 32015, myppid = 13628
I'm child thread 1, my pid = 32015, my father pid = 13628
I'm child thread 1, my tid = 140234367682304								   # 使用pthread_self()打印
I'm child thread 2, my pid = 32015, my father pid = 13628
I'm child thread 2, my tid = 140234359289600
I'm child thread 3, my pid = 32015, my father pid = 13628
I'm child thread 3, my tid = 140234350896896
I'm child thread 4, my pid = 32015, my father pid = 13628
I'm child thread 4, my tid = 140234342504192
I'm child thread 0, my pid = 32015, my father pid = 13628
I'm child thread 0, my tid = 140234376075008

[clx@VM-20-6-centos ~]$ ps -aL | head -1 && ps -aL | grep clxtest | grep -v grep
  PID   LWP TTY          TIME CMD
32015 32015 pts/11   00:00:00 clxtest
32015 32016 pts/11   00:00:00 clxtest   # 使用ps -aL 指令获取
32015 32017 pts/11   00:00:00 clxtest
32015 32018 pts/11   00:00:00 clxtest
32015 32019 pts/11   00:00:00 clxtest
32015 32020 pts/11   00:00:00 clxtest

可以观察到我们通过函数获得的tid和内核的LWP的值时不相等的,pthread函数获得的是用户级线程库的线程ID,LWP是内核轻量级进程ID,它们之间是一一对应的关系

线程等待

一个线程被创建就如同进程一般,是需要执行某种特定任务的,用户需要知道任务处理的怎么样了(成功 or 失败),所以主线程也是需要等待子线程的。如果主线程不对子线程进行等待,那么这个线程的资源也就无法被回收,也会产生类似“僵尸进程”的问题

int pthread_join(pthread_t thread, void **retval); // 注意:pthread_join 函数默认时以阻塞的方式进行等待的
  • thread 需要等待的线程ID

  • retval : 线程退出时的退出码信息

  • 成功返回0,失败返回错误码

调用该函数可以将线程挂起等待,直到 tid = thread的线程终止,并且该线程终止方法不同,pthread_join获得到的终止装填也是不同的

1、如果thread 线程通过return 返回或者pthread_exit()返回,那么retval参数指向的就是该线程的返回值

2、如果thread 线程被其它线程调用pthread_cancel()异常终止掉,retval参数指向的单元存储的就是常数PTHREAD_CANCELED

3、如果对线程的终止状态不感兴趣,也可以传NULL给retval参数

[clx@VM-20-6-centos pthread_api]$ grep -ER "PTHREAD_CANCELED" /usr/include/
/usr/include/pthread.h:#define PTHREAD_CANCELED ((void *) -1)  # 可以看到PTHREAD_CANCELED 就是 -1
void* Routine3(void* arg) {
  char* msg = (char*)arg;
    printf("I'm child %s, my pid = %d, my father pid = %d\n", msg, getpid(), getppid());
    printf("I'm child %s, my tid = %ld\n", msg, pthread_self());
    sleep(2);
  return (void*)2023;
}


void pthread_join_test1(){
  pthread_t tids[5];
  for (int i = 0; i < 5; i++) {
    char* buffer = (char*)malloc(64);
    sprintf(buffer, "thread %d", i);     // 输出文字到buffer中
    pthread_create(tids + i, NULL, Routine3, (char*)buffer);
    printf("I'm main thread, I created child thread %d, child thread tid = %ld\n", i, tids[i]);
  }
  for (int i = 0; i < 5; i++) {
    void* ret = NULL;
    pthread_join(tids[i], &ret);
    printf("thread %d[%lu]...quit, exitcode: %ld\n", i, tids[i], (long)ret);
  }
}

那么为什么线程退出时只能拿到线程的退出码,而没有退出信号以及core dump标志

因为线程同进程一样,线程退出的情况也是 代码运行结束,结果正确/不正确,和异常终止。所以我们也必须考虑线程异常终止的情况。但是因为线程是进程的一个执行分支,如果进程中的某个线程崩溃了,整个进程也会因此崩溃。此时还没有执行pthread_join函数,进程就退出了。所以pthread_join函数只能获取到线程正常退出情况下的退出码,用于判断线程运行结果是否正确

线程终止

线程终止有三种方法:从线程函数中 return, 调用pthread_exit()终止自己,调用pthread_cancel()函数终止同一个进程中的另外一个线程

 int pthread_cancel(pthread_t thread);
  • thread : 被取消的线程ID
  • 线程取消成功返回0,失败返回错误码

线程是可以自己取消自己的,取消成功的线程退出码一般会被设置成-1,但是我们一般不这样做,通常都是使用主线程取消新线程。

void* Routine4(void* arg) {
  char* msg = (char*)arg;
  printf("I'm child %s, my pid = %d, my father pid = %d\n", msg, getpid(), getppid());
  printf("I'm child %s, my tid = %ld\n", msg, pthread_self());
  sleep(2);
  // pthread_cancel(pthread_self());    // 自己取消自己
  return (void*)2023;
}


void pthread_cancel_test1(){
  pthread_t tids[5];
  for (int i = 0; i < 5; i++) {
    char* buffer = (char*)malloc(64);
    sprintf(buffer, "thread %d", i);     // 输出文字到buffer中
    pthread_create(tids + i, NULL, Routine4, (char*)buffer);
    printf("I'm main thread, I created child thread %d, child thread tid = %ld\n", i, tids[i]);
  }
  for (int i = 0; i < 5; i++) {          // 主线程取消子线程
    pthread_cancel(tids[i]);
  }

  for (int i = 0; i < 5; i++) {
    void* ret = NULL;
    pthread_join(tids[i], &ret);
    printf("thread %d[%lu]...quit, exitcode: %ld\n", i, tids[i], (long)ret);
  }
}

当然也存在新线程取消主线程的情况

void* Routine5(void* arg) {
  pthread_t main_tid = *(pthread_t*)arg;
  delete (pthread_t*)arg;
  pthread_cancel(main_tid);
  int count = 0;
  while (count < 5){
    count++;
    cout << "child thread running..." << endl;
    sleep(2);
  }
  return (void*)2023;
}

// 子线程取消主线程
void pthread_cancel_test2(){
  pthread_t tid;
  pthread_t* main_id = new pthread_t(pthread_self());
  pthread_create(&tid, NULL, Routine5, (void*)main_id);
  printf("I'm main thread, I created child thread, child thread tid = %ld\n", tid);
  void* ret;
  pthread_join(tid, &ret);
  cout << "child thread quit... exitcode = " << (long)ret << endl;
}


######################################
  PID   LWP TTY          TIME CMD
 5920  5920 pts/12   00:00:00 clxtest <defunct>   // 可以看到主线程失效
 5920  5922 pts/12   00:00:00 clxtest

I'm main thread, I created child thread, child thread tid = 139622965786368     // 主线程失效后子线程任然可以运行
child thread running...
child thread running...
child thread running...
child thread running...
child thread running...

注意:当采用这种方式取消主线程可以发现,主线程和新线程的地位是对等的。即使主线程被取消,也不影响其它线程执行后续代码。但正常情况下我们呢都是使用主线程去控制新线程,这样符合我们线程控制的基本逻辑。所以这种方法并不推荐

线程分离
int pthread_detach(pthread_t thread);
  • thread: 被分离的线程ID
  • 线程分离成功返回0,失败返回错误码

如果在线程执行函数中调用pthread_detach(pthread_self()) 函数就可以将子线程分离出去,当然这个操作也可以在主线程中执行。被设置的的线程,系统会自动回收对应的线程资源,不需要主线程进行join

线程ID以及进程地址空间分布

  • pthread_create()会产生一个线程ID,该线程ID和内核的LWP不一样。内核中的LWP属于进程调度范畴,因为线程是轻量级进程,是操作系统最小单位,需要一个数值来唯一标识该进程
  • pthread_create()函数第一个参数指向一个虚拟内存单元,该单元的地址即为新创建线程ID,这地址在NPTL线程库中,线程库后续就是通过这个地址来读取线程数据来操作线程的

线程库其实就是一个动态库,进程运行动态库被加载到内存,然后通过页表映射到进程地址空间中的共享区,此时进程内部所有线程都可以看到动态库中的数据。我们所说的每个线程都有自己私有的栈,其中除了主线程采用的栈是进程地址空间原生的栈,其余的线程采用的栈就是在共享区中开辟的。除此之外,每个线程都有自己的struct pthread,当中包含了对应线程的各种属性,每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据,因此我们想要找到一个用户级线程只需要找到该进程内存块的地址,之后就可以从该结构体中获取线程的各种信息

所以我们所用的各种线程函数,本质就是在库内部对线程执行各种操作,最后将需要执行的代码交给对应的内核级LWP去执行就行了,也就是说线程数据的管理本质是在共享区的

// 使用打印地址的方式打印tid

void* Routine6(void* arg) {
  while (1) {
    printf("child thread tid : %p\n", pthread_self());
    sleep(1);
  }
}

void pthread_address_test(){
  pthread_t tid;
  pthread_create(&tid, NULL, Routine6, NULL);
  while (1) {
    printf("main thread tid : %p\n", pthread_self());
    sleep(2);
  }
}

就可以从该结构体中获取线程的各种信息

所以我们所用的各种线程函数,本质就是在库内部对线程执行各种操作,最后将需要执行的代码交给对应的内核级LWP去执行就行了,也就是说线程数据的管理本质是在共享区的

// 使用打印地址的方式打印tid

void* Routine6(void* arg) {
  while (1) {
    printf("child thread tid : %p\n", pthread_self());
    sleep(1);
  }
}

void pthread_address_test(){
  pthread_t tid;
  pthread_create(&tid, NULL, Routine6, NULL);
  while (1) {
    printf("main thread tid : %p\n", pthread_self());
    sleep(2);
  }
}
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小白在进击

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值