Linux线程概念
什么是线程
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
进程
内核观点:线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体。
每个进程都有自己独立的进程地址空间和独立的页表,也就意味着所有进程在运行时本身就具有独立性。
进程:内部的若干个执行流(线程)+ 进程地址空间 + 页表 + 内核的数据结构 + 当前进程的代码和数据。
线程
创建线程时,只创建task_struct
,并要求创建出来的线程是共享进程地址空间(虚拟地址空间)和页表。在Linux系统中,进程和线程,是没有进行区分的,线程的切换和进程一样。
进程的切换是:CPU在执行OS(操作系统)的代码,由操作系统进行切换的,OS会比较进程地址空间是否相同,如果相同的话就不需要更改进程地址空间和页表。
在CPU内部有不同的硬件,其中有一个硬件是高速缓存(L1,L2,L3),CPU的加载策略是局部性原理的,会加载附近的资源,在同进程的线程间切换,高速缓存中存放的数据不用换。当进程间切换时,进程地址空间和页表要更换,高速缓存中的原始数据要设置为无效,然后在加载当前进程的代码和数据到高速缓存中,所有线程的调度成本比较低。
每一个线程的PCB都存放着同进程中线程上下文信息,上线文信息中有指向的要执行的代码,CPU执行不同的PCB就实现了线程的并行执行了。
windows和linux进程和线程的区别
- 进程拥有进程描述符,描述地址空间,打开的文件等共享的资源,还有指向线程的TCB(线程控制块)的指针,而线程没有进程描述符,只是拥有一些少量的私有数据,线程还有独立的调度算法。这是Windows 操作系统对线程的实现。
- Linux内核的设计者,觉着线程要拥有进程一样的属性集合,所以复用了进程的PCB结构体,用PCB模拟线程的TCB。Linux没有真正意义上的线程,而是用进程的方案模拟的线程。
- 复用的代码机构简单,好维护,效率高,也更安全,所有Linux可以不间断的运行。实际上,一款操作系统,使用最频繁功能,除了操作系统本身,其次就是进程了。在使用电脑时主要的都是在和进程打交道,进程这个设计方案如果你设计的很臃肿,不好维护的话,就会容易问题,因为它数据结构更复杂,所以效率高。因此Linux比Windows更适合做服务器。
linux下的线程称为轻量级进程
使用ps -aL
命令,可以显示当前的轻量级进程。
- 默认情况下,不带
-L
,看到的就是一个个的进程。 - 带
-L
就可以查看到每个进程内的多个轻量级进程。
如果要调度的进程的PID与当前正在执行的进程PID不同,就知道要切换上下文和缓存,进程地址和页表,通过加这个判断就可以确定是进程切换还是线程切换。
页表
虚拟地址空间中的地址通常以字节为单位。一个地址通常指向一个字节的数据。对于 32 位系统,虚拟地址空间通常是 4 GB(2^32 字节)。32操作系统采用的二级页表的方式,64操作系统采用的三级页表的方式。
操作系统通常只为已分配或已申请的内存创建页表条目。当进程请求内存时,操作系统会为其分配物理内存,并创建相应的页表条目。对于虚拟地址空间中未分配的内存区域,操作系统不会创建页表条目。
当调用 malloc
函数时,它首先会在虚拟地址空间中为请求的内存分配一个地址范围。这个地址范围在虚拟地址空间中是连续的,但它尚未映射到物理内存。当程序尝试访问这个虚拟地址空间中的地址时,如果对应的物理内存尚未分配,会发生缺页中断。缺页中断发生后,操作系统会处理这个中断,为这个虚拟地址分配物理内存。操作系统会从可用的物理内存中分配一块足够大的连续空间,用于映射到这个虚拟地址空间中的地址。一旦物理内存被分配,操作系统会更新页表条目,将虚拟地址空间中的页面映射到物理内存中的对应位置。页表更新确保了程序可以正确地访问虚拟地址空间中的数据。然后继续执行后续的代码。
页表不仅记录了虚拟地址到物理地址的映射,还包含了页面的访问权限信息。这些权限决定了进程可以对页面执行的操作类型。
MMU
是一种硬件设备,也称为内存管理单元,它位于计算机系统的中央处理器 (CPU) 和内存之间。 MMU负责处理程序发出的内存访问请求,并将逻辑地址转换为物理地址,实现对内存的管理和保护。
char* s = "hello world!";
*s = 's'; // 字符常最区是不允许被修改,只允许被读取的。
s
里面保存的是指向字符串的虚拟起始地址 ,*s
寻址的时候,必定会伴随虚拟到物理的转化,就是通过MMU
+ 查页表的方式, 对你的操作进行权限审查,发现你的权限是只读,但是的操作写,所有是非法的, MMU
发生异常,OS识别异常 ,异常转换成信号,发送给目标进程, 在从内核转换成为用户态的时候,进行信号处理终止进程。
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
概念说明:
- 计算密集型:执行流的大部分任务,主要以计算为主。比如加密解密、大数据查找等。
- IO密集型:执行流的大部分任务,主要以IO为主。比如刷磁盘、访问数据库、访问网络等。
线程的缺点
- 性能损失: 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低: 多线程程序中,任何一个线程崩溃,最后都会导致进程崩溃的现象。
- 缺乏访问控制: 多个线程同时访问和修改同一个共享资源,如果没有适当的同步措施,可能会导致数据不一致或丢失。
- 编程难度提高: 编写与调试一个多线程程序比单线程程序困难得多。
线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
Linux进程VS线程
进程是承担,分配系统资源的基本实体。线程是进程调度的基本单位。
线程拥有的独立的资源
- 线程ID(LWP)
- 一组寄存器。(存储每个线程的上下文信息)
- 栈。(每个线程都有临时的数据,需要压栈出栈)
- errno。(C语言提供的全局变量,每个线程都有自己的)
- 信号屏蔽字。
- 调度优先级。
进程的多个线程共享 同一地址空间,因此代码段(Text Segment)、数据段(Data Segment)都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境
- 文件描述符表。(进程打开一个文件后,其他线程也能够看到)
- 每种信号的处理方式。(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
- 当前工作目录。(cwd)
- 用户ID和组ID
Linux线程控制
Linux下没有真正意义的线程,而是用进程模拟的线程 (LWP) - 所以,Linux不会提供直接创建线程的系统调用,他会给我们最多提供创建轻量级进程的接口。要使用这些函数库,要通过引入头文件<pthreaad.h>
。链接这些线程函数库时,要使用编译器命令的-lpthread
选项。
pthread_create与pthread_self
pthread_exit
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_join
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
- 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
进程才会考虑异常问题,线程不考虑。如果线程异常了,就是进程异常的问题。
pthread_cancel
一个线程跑起来了,才能取消。虽然线程可以自己取消自己,但一般不这样做,我们往往是用于一个线程取消另一个线程,比如主线程取消新线程。
pthread_datach
默认情况下,线程退出后,需要对其进行pthread_join
操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,pthread_join
是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
一个线程如果被分离,就无法被pthread_join
, 如果pthread_join
,函数会报错。
pthread_t
到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t
类型的线程ID,本质就是一个进程地址空间上的一个地址。
所有的线程都有自己独立的栈结构,主线程用的是系统栈,新线程用的是库提供的栈。
struct pthread,当中包含了对应线程的各种属性,它通常包含线程的各种属性和上下文信息。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg)
{
while (1){
printf("次线程: %p\n", pthread_self());
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, NULL);
while (1){
printf("主线程: %p\n", pthread_self());
sleep(2);
}
return 0;
}
Linux线程互斥
互斥: 是指在同一时刻只允许一个线程或进程访问某个资源或执行某段代码的技术。
锁:: 是一种实现互斥的机制,它允许线程获取对共享资源的独占访问权。
互斥量: 是一种用于同步线程的机制,它可以防止多个线程同时访问共享资源。
进程线程间的互斥相关背景概念
- 临界资源: 多线程执行流共享的资源就叫做临界资源
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
互斥量mutex
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
- 凡是访问同一个临界资源的线程,都要进行加锁保护,而且必须加同一把锁,这个是一个游戏规则,不能有例外。
- 每一个线程访问临界区之前,得加锁,加锁本质是给临界区加锁,加锁的粒度尽量要细一些。
- 线程访问临界区的时候,需要先加锁,所有线程都必须要先看到同一把锁,锁本身就是公共资源, 加锁和解锁本身就是原子的。
- 临界区可以是一行代码,可以是一批代码。
- 线程能被切换,因为在我不在期间,任何人都没有办法进入临界区,因为他无法成功的申请到锁,因为锁被我拿走了。
- 这也正是体现互斥带来的串行化的表现,站在其他线程的角度,对其他线程有意义的状态就是:锁被我申请(持有锁),锁被我释放了(不持有锁), 原子性就体现在这里。
- 解锁的过程也被设计成为原子的。
互斥量的接口
PTHREAD_MUTEX_INITIALIZER 与 pthread_mutex_init
pthread_mutex_destroy
销毁互斥量需要注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁 - 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
pthread_mutex_lock 与 pthread_mutex_unlock
加锁如果申请成功继续向下运行,如果申请失败阻塞挂起。
互斥量实现原理
线程1,执行加锁代码,将数字0放到寄存器 %al
线程1,将mutex中的数据与寄存器%al的数据进行交换
假设线程1的时间片到了,线程1保存寄存器状态、程序计数器以及其他必要的上下文信息,然后线程1会回到就绪队列中等待下一次调度,线程2,开始执行。
操作系统会保存当线程2的寄存器状态、程序计数器以及其他必要的上下文信息,操作系统会从就绪队列中选择一个就绪状态的进程(假设这个进程是线程1),操作系统将线程1的上下文信息(包括寄存器状态和程序计数器)加载到CPU中,继续执行线程1的代码,因为线程1的寄存器的%al > 0
所以线程1是不会阻塞挂起的。只有线程1,进行解锁,在不考虑其他线程的情况下,线程2一旦被唤醒,线程会再次尝试执行pthread_mutex_lock
函数来获取锁。
可重入VS线程安全
概念
线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
常见锁概念
如果某个进程(或线程)在CPU运行中需要等待某个资源就绪,就会在该资源的等待队列中进行排序。因为该进程(或线程)不在运行队列中,而是在等待某种资源就绪的过程,称为阻塞挂起。当该进程(或线程)等待的资源就绪了,会将该进程(或线程)tast_struct
重新挂载到运行队列中,等待被调度器调度执行。
当一个线程尝试获取一个互斥锁但未能成功时,它会被阻塞挂起,直到锁变得可用。一旦锁被释放,内核会从等待队列中唤醒队列中的第一 个线程来获取锁。唤醒线程的过程就是将该线程线程的tast_struct
重新挂载到运行队列中,等待被调度器调度执行。一旦线程被调度并获得了CPU时间片,它将重新执行 pthread_mutex_lock()
函数来尝试获取锁。
死锁: 是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
线程同步
- 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
- 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解
饥饿问题:一个线程不停的加锁与解锁,造成了其他线程只能等待的。
条件变量
- 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
- 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
#include <iostream>
#include <string>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>
using namespace std;
const int num = 5;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *active(void *args)
{
string name = static_cast<const char *>(args);
while (true)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex); // pthread_cond_wait,调用的时候,会自动释放锁
cout << name << " 活动" << endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t tids[num];
for (int i = 0; i < num; i++)
{
char *name = new char[32];
snprintf(name, 32, "thread-%d", i + 1);
pthread_create(tids + i, nullptr, active, name);
}
sleep(3);
while (true)
{
cout << "main thread wakeup thread..." << endl;
//pthread_cond_signal(&cond);
//pthread_cond_broadcast(&cond);
sleep(1);
}
for (int i = 0; i < num; i++)
{
pthread_join(tids[i], nullptr);
}
return 0;
}
条件变量接口
PTHREAD_COND_INITIALIZER与pthread_cond_init
pthread_cond_destroy
pthread_cond_signal 与 pthread_cond_broadcast
pthread_cond_wait
//交叉打印100以内的数
#include <iostream>
#include <pthread.h>
#include <string>
using namespace std;
int i = 1;
pthread_mutex_t mutex;
pthread_cond_t cond;
void* myprintf_1(void* arg)
{
string name = static_cast<const char *>(arg);
while(i < 100)
{
pthread_mutex_lock(&mutex);
if(i % 2 != 1)
{
pthread_cond_wait(&cond, &mutex);
}
std::cout << name << i++ << std::endl;
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);
}
}
void* myprintf_2(void* arg)
{
string name = static_cast<const char *>(arg);
while(i <= 100)
{
pthread_mutex_lock(&mutex);
if(i % 2 != 0)
{
pthread_cond_wait(&cond, &mutex);
}
std::cout << name << i++ << std::endl;
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);
}
}
int main()
{
pthread_mutex_init(&mutex, nullptr);
pthread_cond_init(&cond, nullptr);
pthread_t t1;
pthread_t t2;
pthread_create(&t1, nullptr, myprintf_1, (void*)"thread 1: ");
pthread_create(&t2, nullptr, myprintf_2, (void*)"thread 2: ");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
POSIX信号量
信号量:本质就是一个计算器,信号量是对临界资源的预定机制。
- 我们将可能会被多个执行流同时访问的资源叫做临界资源,临界资源需要进行保护否则会出现数据不一致等问题。
- 当我们仅用一个互斥锁对临界资源进行保护时,相当于我们将这块临界资源看作一个整体,同一时刻只允许一个执行流对这块临界资源进行访问。
- 但实际我们可以将这块临界资源再分割为多个区域,当多个执行流需要访问临界资源时,如果这些执行流访问的是临界资源的不同区域,那么我们可以让这些执行流同时访问临界资源的不同区域,此时不会出现数据不一致等问题。
信号量接口
sem_init
sem_destroy
sem_wait
sem_wait
信号量的值大于零,则将其减一,并允许调用线程继续执行;如果信号量的值为零,则调用线程将被阻塞,直到信号量的值大于零为止。sem_wait
函数是原子性的
sem_post
sem_post
来增加信号量的值,并可能唤醒一个或多个等待该信号量的线程。sem_post
操作也是原子性的