Linux线程(Linux和Windows的线程区别、Linux的线程函数、互斥、同步)
1. 线程介绍
线程的概念:
线程是 CPU 调度的基本单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
线程和进程的区别:
-
线程是 CPU 调度的基本单位,而进程(内核角度)是承担分配系统资源的基本实体。
-
线程和进程最大的区别是,线程之间共用一个地址空间,而进程的地址空间彼此独立。
线程独立的:
线程ID、一组寄存器、栈、errno、信号屏蔽字、调度优先级
线程共享的:
文件描述符表、每种信号的处理方式( SIG_IGN 、SIG_DFL 或者自定义的信号处理函数)、当前工作目录、用户 id 和组 id
2. Linux和Windows的线程区别
Linux 和 Windows 操作系统之间,采取了完全不同的线程设计思路。Linux 通过进程来模拟线程。由于线程和进程的内容重复度高(都有独立的栈、CPU 调度都要切换上下文等), 所以 Linux 选择了复用进程 PCB ,用 PCB 统一表示执行流。因此,在Linux系统中,实际没有线程和进程的概念,Linux中的 CPU 也不区分进程和线程,统一视为执行流。Linux中的执行流被称为轻量级进程。Linux复用 PCB 模拟线程,优点是不需要为线程设计单独的数据结构和调度算法。
Windows 创建了独立的 TCB 结构体来创建线程,将线程和进程的概念从底层就完全区分开,这意味着 Windows 的进程和线程是独立的,线程有独立的管理线程 id 、优先级、状态、上下文、连接属性的数据结构,即 Windows 需要分别对进程和线程的代码进行维护,而 ,Windows 对于线程的维护成本要比Linux高。
Linux 不区分线程和进程的概念,使得它的线程相关的系统调用,是以 “轻量级进程” 的角度和逻辑来设计和使用的。但对于普通程序员来说,我们并不关心什么 “轻量级进程” ,只是想要使用线程来实现代码的并发。所以,为了解决这个问题,C 语言在 Linux 系统中提供了一个 pthread
库,这个库封装了 Linux 轻量级进程的系统调用,提供了以线程的角度和逻辑来使用轻量级进程的接口。但要注意,使用这个库时,必须要在编译命令上加上一段 -lpthread
,这是因为 pthread
库并不是系统库,但它是系统默认安装的库,它的绝对路径已经被系统环境变量保存下来了,使用时只需要告诉编译器要使用这个第三方库即可。
3. 线程的优缺点
3.1 优点
-
创建线程的成本低。
创建进程的成本比创建线程的成本高,因为创建进程意味着要创建PCB、地址空间、页表、构建映射关系……。而创建线程只需要创建PCB。
-
线程的调度成本低。
CPU中有一个硬件cache,cache存储着内存的热数据(使用频率高的内存块),CPU在对热数据进行操作时,都是在调度cache里的数据。进程因为有独立的上下文数据,即使不同进程内的cache数据相同,但切换进程寄存器需要恢复彼此的数据,即cache内的数据不互通。但线程共享同一地址空间、上下文数据,它们的cache是互通的,使得线程效率比进程高。
-
线程删除成本低。
删除进程要删除PCB、地址空间、页表、构建映射关系……,释放代码和数据。但删除线程只需要删除PCB。
-
线程占用的资源比进程少很多。
-
能充分利用多处理器的可并行数量。
-
在等待慢速I/O操作结束的同时,程序可以执行其他的计算任务。
-
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
-
I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待多个不同的I/O操作。
3.2 缺点
-
性能缺失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
-
健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
-
缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
-
线程不具有进程的独立性,当一个线程崩溃时,其他线程也会挂掉。
进程和线程崩溃时,OS都会向其发送终止信号。进程因为彼此独立,一个挂掉了也不影响另一个;但线程没有进程的独立性,收到终止信号后会一起被杀死。
合理的使用多线程,能提高CPU密集型程序的执行效率,提高IO密集型程序的用户体验。
4.线程和地址空间
函数的地址是一批代码的入口地址,函数内每一行代码都有地址,而且同一个函数的地址是连续的。连续的代码地址构成代码块。对函数的拆分,实际就是对地址的拆分,即通过拆分页表实现。把虚拟地址给了谁,谁就拥有了这块虚拟地址,所以虚拟地址的本质是一种资源。
5.Linux的线程函数
Linux 没有线程的概念,只有轻量级进程,所以 Linux 只有轻量级进程的系统调用,用户要使用线程的接口,就需要对轻量级进程接口进行封装,按照线程的接口方式交给用户。所以,在 Linux 中可以说是用户级线程,Windows 是内核级线程。
Linux自带的原生线程库 pthread 库,但该库并非 Linux 系统的默认库,所以在编译时要指明:
gcc -lpthread
5.1 创建线程函数pthread_create
这个函数用于创建一个线程。pthread_t
这个类型本质是 unsigned long int
,库对其进行了封装。
#include <pthread.h>
int pthread_create(pthread_t* thread,const pthread_attr_t* attr,void*(*start_routine)(void *),void* arg)
pthread_t* thread
:这是一个输出型参数,它会返回创建的线程id。
const pthread_attr_t* attr
:用来设置线程属性。使用时设置为nullptr就可以,一般不需要改。
void*(*start_routine)(void *)
:函数指针,线程要执行的函数的地址。
void* arg
:这个参数会作为线程要执行的函数的参数。
reval
:成功返回0,失败返回错误码。
因为 void* arg
是 void*
类型参数,所以在传参的时候可以传递类类型的地址作为参数,即可以给线程传递多个参数、方法。
上面提到每个线程都有独立的栈,这种传参方式使得其他线程可以间接访问主线程的栈的数据,但由于线程谁先运行是不确定的,这些的操作有巨大的风险,即由于先后次序不确定,其他线程对主线程的栈上数据进行更改操作的时间也是不确定的。
所以在传递类类型作为参数的时候,一般会在堆上开空间(new一个对象),再把这个地址交给新线程,这样这个类类型对象就专属于新线程。
5.2 等待线程函数pthread_join
主线程和 new 出来的新线程运行顺序是不确定的,有可能主线程运行结束了新线程还没开始运行,也有可能新线程运行结束了主线程还没结束,但我们一般期望主线程(main函数)是最后退出的。因为主线程退出,进程就结束了,其他线程还没执行完代码也会强制结束,所以需要在主线程里等待其他线程退出。
pthread_join
就是一个用于等待线程的函数,这个函数会阻塞等待特定的进程退出。且即使其他线程都退出了,main()
还没退出,不使用 join 会造成类似僵尸进程的问题。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
pthread_t thread
:线程id
void **retval
:输出型参数,这个参数是获取线程返回值的地址,因为线程函数的返回值是void*
故这个参数的类型是void**
线程退出,只考虑正确退出,不考虑异常退出,因为异常了整个进程就崩溃了,所以对于 pthread_join
的返回值我们不需要考虑。
5.3 终止线程函数若干
进程终止的方法有多种,可以通过函数return返回,pthread_exit()
函数和 pthread_cancel()
函数。但切忌不能使用 exit()
函数退出。
void pthread_exit(void *retval)
是一个专门用来终止线程的函数,它用于线程自行调用退出。
int pthread_cancel(pthread_t thread)
用于在主线程取消其他线程,线程被取消退出结果是 -1
,本质是定义了一个宏PTHREAD_CANCELED ((void*)-1)
。
5.4 线程分离函数pthread_detach
一个线程被创建默认是 joinable 的,且必须要 join 。但如果一个线程被分离(detach),线程的工作状态就变成分离状态,不需要被join 也不能被 join(被j oin 会报错)。 线程分离运行主线程脱离等待线程退出的阻塞状态,转而去执行其他操作。
int pthread_detach(pthread_t thread)
pthread_t thread
:传入线程的tid即可将线程变更为分离状态。
pthread_t pthread_self(void)
这个函数用于线程获取自己的线程id,线程可以使用该函数自行分离。
6. C++11的多线程
#include<thread>
C++11 支持了多线程,使用 thread
类类型创建线程,但在编译时仍需要 -lpthread
,所以在 Linux 中,c++11的多线程本质就是对原生线程库接口的封装。C++11 这样做的目的,是为了将 Linux、Windows 和 macOS 中 C++ 使用多线程的方式统一,实际上都是对系统的原生线程库接口进行了封装。
所以 pthread 库创建线程,本质也是通过系统调用:
int clone(int (*fn)(void *), void *child_stack,int flags, void *arg, .../* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
这个函数是由 Linux 提供的创建轻量级进程的函数,它可以自定义设置栈的大小,存放的位置等信息,
pthread_create()
本质就是对其做了封装。
c++提供了 __pthread
关键字,它允许一个全局变量分别在线程之间私有,修改 __pthread
的变量互不影响,本质是编译器生成了两份数据,因为它们的地址并不相同。这种写法只在 Linux 下有效,且只适用于内置类型。
7. 线程id
pthread库提供给用户提供的线程 id 不是内核的轻量级进程 ,而是 pthread 库维护的一个值。由于 Linux 没有线程的概念,pthread 库就需要自己手搓一个线程属性集合( TCB ),它类似于进程的PCB,只不过PCB是由系统维护的,但 TCB 是 pthread 库维护的。
线程属性集合包含了struct_pthread、线程局部存储、线程栈等信息,所以线程的tid本质是一个指向线程属性集合的起始虚拟地址,在pthread 库中维护。
由于线程属性集合都是pthread动态库管理的,它在地址空间的位置位于堆栈之间的共享区。
线程在退出时,会将退出状态写入 struct_pthread
结构体中的某个位置(void ret),将除此之外的其他线程相关的属性都释放掉,等用户通过 pthread_join
函数获取退出状态,并释放资源*。所以说不使用 pthread_join
有可能导致内存泄漏,就是因为不通过 join 获取线程退出状态, struct_pthread
中对应的资源可能会一直保留不释放。
所以,Linux 线程可以认为是由pthread 库中线程的属性集和轻量级进程组成的。
8. 线程的互斥
8.1 需要互斥的原因
临界资源是多线程执行流共享的资源,而临界区是每个线程内部,访问临界区的代码。
互斥就是对资源(临界资源)进行保护,即在任何时刻,只允许一个线程进行资源访问。如A线程在对文件写入,线程B对该文件读,有可能发生线程A还没写完数据,线程B就将未写完的数据读出的情况发生,互斥就是对这种情况进行保护。
由于线程有自己的栈,CPU在调度时它的寄存器需要保存线程的数据,在下一次调度时恢复数据。在某些场景下:
int count=1;//count是全局变量
void func()
{while(true){if(count>0){count--;}else{break;}}
}
CPU 中的 eax 寄存器负责进行逻辑运算和算数运算,并在时间片到期时保存运算的结果。
假设将 func()
交给多个线程去执行,CPU 在调度 A 线程时,eax 刚把 if(count > 0)
代码的结果算出来,且为真(1
)就切换了线程B。由于此时 count
还是 1
,CPU 在调度线程 B 时,eax 也把if(count > 0)
的结果运算为真,并执行了count--
代码,此时全局变量 count
值为 0
。当CPU重新调度线程A时,寄存器恢复了线程 A 的上下文,eax 寄存器中线程 A 的结果仍为真,于是线程 A 也执行了执行了count--
代码。最终 count
的值就变为了 -1
。
8.2 线程锁
pthread库中提供了互斥锁来保护资源,其中pthread_mutex_t
是互斥锁类型,使用该类型实例化一个互斥锁即可用于保护资源。pthread库也提供一些用于互斥锁的库函数。需要注意的是,如果互斥锁是全局或静态的,只需要对其 init,不需要 destroy 。
设置删除相关:
int pthread_mutex_destroy(pthread_mutex_t *mutex)
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr)
第一个参数传入锁的地址,第二个参数attr指定了新建互斥锁的属性,若为空则使用默认的互斥锁属性,默认属性为快速互斥锁 。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
加锁解锁相关:
int pthread_mutex_lock(pthread_mutex_t *mutex)
int pthread_mutex_trylock(pthread_mutex_t *mutex)
int pthread_mutex_unlock(pthread_mutex_t *mutex)
使用方法:
-
使用
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
语句创建一个名为mutex(或其他)的互斥锁。 -
在需要保护的临界区(代码段)使用
pthread_mutex_lock()
函数加锁。 -
在临界区(代码段)结束行使用
pthread_mutex_unlock()
函数解锁。
线程之间设置互斥锁后,被锁的代码段在执行时明显速度会下降,这是因为互斥锁只允许一个线程拥有访问临界区的权限,其他的线程虽然时间片轮询到它们,由于这些线程没有成功申请锁,它们会被 pthread_mutex_lock()
函数阻塞,直到拥有锁的线程解锁成功。所以,在使用锁时,加锁的范围、粒度一定要尽量小。
所有线程申请锁,前提是所有的线程都能看到锁,即锁本身也是共享资源,那么,加锁的过程也必须是原子的。所谓的原子性,就是一个事务要么是做了,要么是没做,没有做到一半的中间状态。
8.3 加锁解锁的原理
实现互斥锁是通过系统提供的 swap 或 exchange 指令,将寄存器和内存单元的数据进行交换,该指令由于只有一条,故具有原子性。
加锁:
现令每个线程的寄存器中上下文数据初始化为0,线程在加锁的过程中,通过调用swap或exchange指令将内存中的1交换过来,判断只有寄存器内为1的线程上下文为申请锁成功,其他线程由于内存中的值为0,交换后寄存器的值也为0,只能阻塞等待。
解锁:
将寄存器上下文含有1的线程与内存单元数据交换,此时CPU中相关位置的值又回到最初的状态,即实现解锁。
lock:move $0, &alxchgb %al, mutexif(al寄存器的值>0){return 0;}else挂起等待;unlock:move $1, mutex唤起等待Mutex的线程;return 0;
8.4 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配
9. 线程的同步
同步,即在保证临界资源安全的前提下,让执行流访问临界资源有一定的顺序。
假设现有两个线程向同一份临界资源里读写数据。A线程向临界资源拿数据,B线程向临界资源放数据,两个线程竞争同一把锁。若其中一方竞争锁的能力大于另一方,如A总是能申请到锁,所以B线程没有机会向临界资源里放数据,但A总是要访问临界资源才能知道里面是否有数据,于是就造成了B线程的饥饿问题。对于这种情景,我们希望线程能按照一定的顺序访问临界资源(申请到锁),于是就需要条件变量来控制。
9.1 条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr)
pthread_cond_t cond = PTHREAD_COND_INITIALIZER
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex)
线程在调用这个函数时,除了让自己排队等待,还会释放自己传入的锁。返回时,必须先参与锁的竞争,重新上锁才会返回。
int pthread_cond_broadcast(pthread_cond_t *cond)
int pthread_cond_signal(pthread_cond_t *cond)
9.2 信号量
信号量是一种资源的预定机制。预定,即在外部,可以不判断满足资源是否满足条件,就可以知道资源内部的情况。信号量保证它的增减操作皆为原子操作,增操作称为P操作,减操作称为V操作。
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value)
int sem_destroy(sem_t *sem);
int sem_wait(sem_t *sem);
sem_wait()
是一个原子操作,它的作用是从信号量的值减去一个“1”,但它永远会先等待该信号量为一个非零值才开始做减法。如果信号量为2,调用
sem_wait()
,线程将会继续执行,将信号量的值将减到1;如果信号量为0,调用sem_wait()
,这个函数就会阻塞等待直到有其它线程将信号量增加到不为0。如果有两个线程都在
sem_wait()
中等待同一个信号量变成非零值,那么当它被第三个线程增加 一个“1”时,等待线程中只有一个能够对信号量做减法并继续执行,另一个还将处于等待状态。
int sem_post(sem_t *sem);
sem_post()
是一个原子操作,调用这个函数的信号量会增加"1"。
9. 可重入与线程安全
9.1 可重入与线程安全概念
线程安全:
多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:
同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函函数.。
结论:
可重入的函数是线程安全的,但线程安全的函数不一定可重入。
9.2 常见的可重入与线程安全的情况
常见的线程不安全的情况:
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
常见的线程安全的情况:
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作。
多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见不可重入的情况:
调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的。
调用了标准 I/0 库函数,标准 I/0 库的很多实现都以木可重入的方式使用全局数据结构。
可重入函数体内使用了静态的数据结构。
常见可重入的情况:
不使用全局变量或静态变量。
不使用 malloc 或者new 开辟出的空间。
不调用不可重入函数。
不返回静态或全局数据,所有数据都有函数的调用者提供。
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
9.3 可重入与线程安全联系与联系
可重入与线程安全联系:
函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别:
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
10. 相关命令
查看运行中的线程的命令
ps -aL #LWP表示light weight process 轻量级进程,LWP等于PID的线程是主线程
查看CPU属性的指令
lscup
static_cast
static_cast<void*>()
是一个能够安全强制类型转换的类型,对于不能强转的情况会出现报错。由于线程有大量的 void*
类型的参数,所以有大量需要强转的情况,这个类类型可以帮助安全编码。