文章目录
- VMA
- ELF
- 地址转换
- 线程
- 相关函数
- 同步和互斥
- 引入条件变量
- 总结条件变量
- PC模型
- 循环队列
- POSIX信号量
- 接口
- posix信号量和systemV信号量
- 主要异同
- 适用场景
- 总结
- 基于循环队列的PCModel
- 锁--条件变量--信号量 的产生由来
- 线程相关问题
- 线程池
- 回顾进程池
VMA
ELF
Executable and Linkable Format二进制文件格式。ELF文件可以是可执行文件、可重定位文件(如.o文件)、共享目标文件(如.so文件)或核心转储文件等。
地址转换
线程
- 进程是操作系统进行资源分配的基本单位,线程是操作系统调度(CPU执行)的基本单位。
- 线程是OS调度的基本单位 ⇒ CPU其实不关心执行流是进程还是线程,只关心PCB。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
- 【不同冲击下选择更好的方案,并不是线程越多越好,如果只有单一的工作,你创建了很多线程,本来单一的工作循环去做不会花费太多时间,此时由于你交给了很多线程,此时花费时间的是线程的来回切换】合理的使用多线程,能提高计算密集型程序的执行效率,能提高IO密集型程序的用户体验(例如边下边播功能,就是多线程运行的一种表现)
- 用户态线程的切换在用户态实现,不需要内核支持。
- 进程比线程安全的原因是每个进程有独立的虚拟地址空间,有自己独有的数据,具有独立性,不会数据共享这个太过宽泛与片面。
- 创建子线程后,主线程同样需要等待子线程退出,获取子线程退出结果,然后操作系统回收子线程PCB。线程等待不需要关心子线程是否异常,因为一旦子线程出现异常,整个进程就会随之崩溃。
- OS管理线程的调度和内核数据结构⇒ 内核级线程;线程库维护线程的id,栈,属性⇒ 用户级线程
- _ _thread是GCC编译器提供的一个关键字,它用于声明线程局部存储(Thread-Local Storage,TLS)的变量。 _ thread修饰的全局变量是每个线程都拥有其独立副本的变量。 _thread变量必须是POD(Plain Old Data)类型,即简单的数据类型,如整数、浮点数和指针,不能是带有自定义构造函数、析构函数或复杂拷贝语义的类类型.
线程共享进程的资源
代码区数据(定义一个函数,在各线程中都可以调用)
静态区数据(定义一个全局变量,在各线程中都可以访问)
堆区数据(堆空间的指针可以在各线程间传递,也可以选择私有堆空间)
共享区数据(动态库和共享内存通信)
命令行参数和环境变量
文件描述符表
每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
当前工作目录
用户id和组id
线程独有数据
线程ID(线程属性结构)
独立栈结构(线程属性结构):线程独立执行的重要依据,调用函数,开辟栈帧空间,存储临时数据
errno错误码(线程局部变量,线程属性结构)
线程上下文(一组寄存器,PCB数据):线程独立调度的重要依据
信号屏蔽字(PCB数据)
调度优先级(PCB数据)
LWP 和 pthread_t id
pthread_t
:用户态标识,用于用户代码管理线程。具体的 pthread_t
实现方式依赖于系统和线程库。在 glibc
中,pthread_t
通常是线程控制块 (TCB
) 的内存地址(64 位系统中为指针类型)。不同的线程可能对应不同的 pthread_t
值,即使它们共享相同的内核资源。
LWP
/TID
:内核态标识,用于线程调度和操作系统管理。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>// g++ test.cc -lpthread -o test
void* thread_func(void* arg)
{pthread_t thread_id = pthread_self(); // 获取线程的 pthread_tpid_t tid = syscall(SYS_gettid); // 获取线程的 TID (LWP)pid_t pid = getpid(); // 获取进程的 PIDsleep(1);std::cout << "Thread: pthread_t = " << thread_id << ", LWP (TID) = " << tid << ", PID = " << pid << std::endl;sleep(1);return nullptr;
}int main()
{pthread_t threads[3];// 创建多个线程for (int i = 0; i < 3; ++i){pthread_create(&threads[i], nullptr, thread_func, nullptr);}sleep(3);// 主线程信息pthread_t main_thread_id = pthread_self(); // 主线程的 pthread_tpid_t tid = syscall(SYS_gettid); // 主线程的 TIDpid_t pid = getpid(); // 主线程的 PIDstd::cout << "Main Thread: pthread_t = " << main_thread_id << ", LWP (TID) = " << tid << ", PID = " << pid << std::endl;sleep(100);// 等待线程结束for (int i = 0; i < 3; ++i){pthread_join(threads[i], nullptr);}return 0;
}
while :; do ps -eLf | head -1 && ps -eLf | grep test;echo "==";sleep 1; done
UID PID PPID LWP C NLWP STIME TTY TIME CMD
lhr 429788 428397 429788 0 4 23:37 pts/0 00:00:00 ./test
lhr 429788 428397 429789 0 4 23:37 pts/0 00:00:00 ./test
lhr 429788 428397 429790 0 4 23:37 pts/0 00:00:00 ./test
lhr 429788 428397 429791 0 4 23:37 pts/0 00:00:00 ./testThread: pthread_t = 140237570549504, LWP (TID) = 429789, PID = 429788
Thread: pthread_t = 140237562156800, LWP (TID) = 429790, PID = 429788
Thread: pthread_t = 140237553764096, LWP (TID) = 429791, PID = 429788
Main Thread: pthread_t = 140237570553664, LWP (TID) = 429788, PID = 429788
相关函数
fork() 创建一个新的进程,这个新进程可以执行与父进程相同的程序,也可以调用 exec() 系列函数来执行另一个程序。
vfork() 创建的子进程并不复制父进程的地址空间,而是共享父进程的地址空间。vfork() 通常用于创建一个新的进程来执行另一个程序(通过 exec() 系列函数)。由于子进程直接在父进程的地址空间中运行,因此这种方式通常比 fork() + 子进程exec() 更高效。
clone() 函数用于创建新的线程。它允许调用者指定哪些资源(如内存、文件描述符等)应该被新线程共享,哪些应该被复制。这使得 clone() 在创建轻量级线程时比 fork() 更高效。
fork() 复制父进程的所有资源;vfork() 与父进程共享地址空间;clone() 可以选择性地复制父进程的资源。
pthread_create是POSIX线程库(Pthreads)中的一个函数,clone是Linux特有的系统调用。
线程内部调用fork()
子进程只会复制调用fork()的那个线程的上下文,包括其栈、寄存器状态等。其他已经创建的线程不会被子进程继承。这意味着子进程将只拥有一个线程,即复制自调用fork()的那个线程的副本。虽然子进程只继承了一个线程,但它仍然继承了父进程的所有其他资源,如内存、文件描述符等。只有调用fork()的那个线程的上下文会被复制到子进程,其他线程不会被子进程继承。
设置为分离状态的线程发生了除零异常
设置为分离状态的线程在结束时,系统会自动回收其占用的资源,而无需主线程或其他线程进行资源的清理操作。
一个设置为分离状态的线程如果发生了除零异常,通常会导致该线程自身崩溃。由于线程是进程的执行分支,线程的崩溃可能会触发信号机制,导致整个进程终止。因此,不仅主线程会受到影响,该进程内的所有线程,包括其他非分离状态的线程,都将被终止。
设置为分离状态的线程
- 主线程必须最后退出,他要回收资源
- 如果主线程先于新线程退出,那么所有其他线程都会不正常的退出
- pthread_detach()的应用是有场景的:主线程创建了一个新线程让他去执行任务而不关心他的退出状态,可以让该新线程设置为分离状态,执行完后自动回收资源
同步和互斥
竞态条件
-
竞态条件(Race Condition)是指多个线程或进程同时访问共享资源,并且对资源的访问顺序不确定,导致最终结果的正确性依赖于线程执行的具体时序。竞态条件可能导致不可预测的结果,破坏程序的正确性和一致性。
-
竞态条件通常发生在多个线程或进程同时对共享资源进行读写操作时,其中至少一个是写操作。当多个线程或进程同时读写共享资源时,由于执行顺序的不确定性,可能会导致数据的不一致性、丢失、覆盖等问题。
-
为了避免竞态条件的发生,可以使用同步机制来限制对共享资源的访问,确保每次只有一个线程可以访问该资源。常见的同步机制包括互斥锁、信号量、条件变量等。此外,还可以使用原子操作和线程安全的数据结构来避免竞态条件的发生。
同步和互斥
- 互斥是为了解决安全问题,用于保护共享资源,确保在任意时刻只有一个线程可以访问该资源。互斥机制通过引入互斥锁(Mutex)来实现,当一个线程获得互斥锁时,其他线程必须等待,直到该线程释放锁才能继续访问共享资源。
- 同步是在互斥保证了安全的前提下协调多个线程或进程的执行顺序,以确保它按照一定的顺序和规则高效地访问资源。同步机制可以通过互斥、信号量、条件变量等方式来实现,以确保线程或进程之间的有序执行。
同步还可以包括其他机制,如条件变量、信号量等,用于实现更复杂的同步需求,例如线程间的通信和协作。
引入条件变量
只通过互斥保护共享资源不能够完全解决竞态条件问题:
-
饥饿问题:在资源竞争的过程中,某执行流频繁的申请到资源,而导致其他执行流被长时间地阻塞或无法获得所需的资源时,就会发生饥饿问题。
-
忙等待问题:当访问临界资源时,除了要申请互斥锁,还要检测资源是否就绪。如上一章互斥锁中多线程售票的例子,还需要检测票数是否大于0,才能进行售票。当资源不就绪时,各线程只能通过频繁的轮询申请释放锁并进行检测才能确定资源是否就绪,浪费了CPU和锁资源。这就是忙等待问题。
完全解决竞态条件问题不仅仅需要通过互斥保护共享资源,还必须通过其他同步机制进行线程间的通信和协作。
条件变量(Condition Variable)是一种线程同步机制,用于线程间的等待和唤醒操作,以实现线程间的协调和通信。条件变量通常与互斥锁(Mutex)结合使用,用于解决线程同步和竞态条件的问题。
条件变量需要与互斥锁配合使用,以保证对共享资源的访问是互斥的。对条件变量的等待和唤醒操作需要在持有互斥锁的情况下进行,以确保操作的原子性和正确性。
条件变量提供了以下两个基本操作:
等待(Wait):一个线程调用等待操作后,会释放持有的互斥锁,并进入等待状态,直到其他线程通过唤醒操作将其唤醒。在被唤醒后,线程会重新申请互斥锁,并继续执行。
唤醒(Signal):一个线程调用唤醒操作后,会选择一个或多个等待在条件变量上的线程,并将其从等待状态唤醒。被唤醒的线程会尝试获取互斥锁,并继续执行。
条件变量配合互斥锁完美的解决了忙等待和饥饿问题。
-
条件变量的等待是一种排队阻塞等待的操作,线程会按照先进先出的顺序等待条件变量唤醒。这在一定程度上解决了多线程的饥饿问题。
-
通过条件变量,线程可以在满足特定条件之前阻塞等待,而不是忙等待,从而提高了系统的效率和性能。同时在阻塞等待之前,它会释放所持有的互斥锁,允许其他线程获得该互斥锁并继续执行。这样,其他线程就有机会在等待期间获取互斥锁,并访问共享资源。这也在一定程度上解决了多线程的饥饿问题。
总结条件变量
-
一个线程需要访问临界资源,如果我们不对其进行条件变量的控制,那么他就可能出现不断地访问该资源,使得其他线程无法访问且在不断地轮询检测资源的状态,这种情况不错但是不合理。引入同步的机制,主要是为了解决 访问临界资源的合理性问题即让线程按照一定的顺序,进行临界资源的访问。
-
当一个线程想要访问临界资源前,先要检测临界资源是否处于有绪状态即是否可以被访问,而“检测”的这个行为本质也是在访问临界资源,那么对临界资源的检测也一定是需要在加锁和解锁之间。
-
常规方式要检测条件就绪,注定了线程必须频繁申请和释放锁。有没有办法让线程检测到资源不就绪的时候,不要让线程在频繁自己检测,而是让他等待,当条件就绪的时候,通知对应的线程,让他来进行资源申请和访问。 ⇒ 条件变量!
为了提高多线程的效率以及彻底达到多线程的目的即让多线程去高效的做任务,我们想让条件满足的时候再唤醒指定的线程 — 怎么知道条件是否满足?
控制/生成这个资源的一方知道什么时候达到了“满足”状态。比如,生产者生产了资源,那么生产者就知道仓库里有资源,那么他就可以通知消费者来获取。又比如,消费者获取了资源,那么消费者就知道仓库里有地方可以存放资源,那么他就可以通知生产者来生产。
PC模型
生产者消费者模型的并发性:通过线程互斥使得临界资源被合法地访问(即临界资源同一时刻只能有一个线程访问),线程同步使得临界资源被合理地访问(即一个线程准备访问时检测到资源不就绪他会释放锁去等待而非轮询访问),互斥与同步使得【生产者发送数据和消费者获取数据】这一行为合理又合法,而做到这一步只是PC模型不值一提但十分重要的一个“点”,PC模型的并发体现在生产者在生产数据时,消费者可以获取数据获处理数据;消费者在处理数据时,生产者可以生产数据或发送数据。毕竟与发送和获取这样的拷贝动作来比,真正耗时的是“生产”和“处理”这两个动作。
阻塞队列原理
在队列为空时,获取元素的线程将会阻塞,直到有元素可获取;当队列已满时,尝试添加元素的线程也将阻塞,直到队列有空余空间。
在生产者消费者模型中,生产者线程负责向队列中添加数据(生产),消费者线程从队列中移除数据(消费)。使用阻塞队列可以确保当队列满时生产者线程阻塞,当队列空时消费者线程阻塞,从而避免数据的丢失或重复处理。
环形队列原理
环形队列(也称为循环队列)是一种使用固定大小数组实现的队列。当队列的尾部到达数组的末尾时,它会循环回到数组的开头。这种设计可以高效地利用数组空间,避免了传统队列在插入和删除元素时可能需要的数组移动操作。
在生产者消费者模型中,环形队列同样用于存储生产者产生的数据,供消费者线程消费。由于环形队列的空间是固定的,因此当队列满时,生产者线程需要等待消费者线程消费数据以释放空间;同样,当队列空时,消费者线程需要等待生产者线程生产数据。
循环队列
一个结构他在内存中的存储样式(存储结构/物理结构)仅仅是一块空间,这块空间能实现什么样的功能取决于设计者在软件层给这块空间维护了怎样的设计。
入队:queue[rear++] = x;
出队:front++;
队空队满条件一样 怎么判断?
通过少用一个空间 把队满的判断条件分离出去 使得空满二者判断时条件不同
1、队空:q.front == q.rear2、队满:(q.rear + 1)% N = q.front3、队长:(q.rear - q.front + N) % N4、循环计数:
q.front = (q.front + 1) % N
q.rear=(q.rear + 1) % N
POSIX信号量
Linux下的POSIX信号量是一种实现进程/线程间通信的机制,主要用于保护共享资源,确保资源在某一时刻只被一个进程(线程)使用。它分为有名信号量和无名信号量两种类型。有名信号量的值保存在文件中,因此它可以用于线程间和进程间的同步;而无名信号量的值保存在内存中,因此它主要用于线程间的同步,如果需要用于进程间同步,则信号量需要放在共享内存中。POSIX信号量包含一个非负整型变量,并带有两个原子操作:wait(也被称为down、P或lock)和signal(也被称为up、V、unlock或post)。
接口
sem_wait P操作 value–
sem_post V操作 value++
- sem_init:初始化一个未命名的信号量。在内存中为信号量分配空间,并设置其初始值。这个初始值通常表示可用资源的数量或允许进入临界区的线程数。
- sem_destroy:销毁一个先前初始化的信号量。释放信号量所占用的内存空间,并取消其关联的任何系统资源。
- sem_post:增加(或“发布”)信号量的值。对信号量的值进行原子性增加操作。这通常意味着释放了一个资源或允许更多的线程进入临界区。这个操作可能会唤醒等待该信号量的一个或多个线程。
- sem_wait:减少(或“等待”)信号量的值。尝试对信号量的值进行原子性减少操作。如果信号量的值大于0,则将其减1并立即返回;如果信号量的值为0,则调用线程或进程将被阻塞,直到信号量的值变为正数。这个阻塞是通过系统调用和内核的调度机制实现的。
posix信号量和systemV信号量
有名信号量的值保存在文件中,因此它可以用于线程间和进程间的同步;
无名信号量的值保存在内存中,因此它主要用于线程间的同步,如果需要用于进程间同步,则信号量需要放在共享内存中。
主要异同
特性 | POSIX信号量 | System V信号量 |
---|---|---|
命名 | 可以是命名的(sem_open )或未命名的(sem_init )。 | 仅支持未命名信号量,通过key_t 标识。 |
创建与销毁 | 使用sem_init 和sem_destroy 进行初始化和销毁。 | 使用semget 创建,使用semctl 删除。 |
跨进程 | 可通过命名信号量实现跨进程同步。 | 支持跨进程同步,通过信号量集实现。 |
操作 | 提供sem_wait , sem_post , sem_trywait , sem_getvalue 等操作。 | 提供semop 进行P/V操作,并利用semctl 进行控制。 |
局部性 | 通常用于线程间同步,也可以用于进程间同步。 | 主要用于进程间同步。 |
API复杂性 | API相对简单且更容易使用。 | API相对复杂,使用多种类型的函数。 |
内存模型 | 信号量对象通常存储在用户空间。 | 信号量信息存储在内核中,使用IPC机制。 |
错误处理 | 错误返回值直接返回。 | 需要分析errno来获取详细错误信息。 |
适用场景
- POSIX信号量:适合于多线程程序,因其提供了易于使用的接口,并且可以在多线程的应用中高效地管理资源。
- System V信号量:由于其强大的功能,适用于需要复杂同步机制的多进程环境。
总结
选择使用POSIX信号量还是System V信号量取决于具体的需求和上下文。对于大多数现代应用,POSIX信号量由于其简洁性和易用性,通常是更受欢迎的选择。但在某些特定情况下,如需要与旧系统兼容或使用特定功能时,可以考虑System V信号量。
基于循环队列的PCModel
先加锁还是先申请信号量?
很明显,我们要先申请信号量,为什么?我们在不加锁的前提下先申请信号量时,此时是可以有多个线程调用push/pop函数的,信号量的值是大于1的,即可以有多个线程成功申请信号量,申请信号量成功的就去申请锁,申请信号量失败的就阻塞等待;而如果我们先申请锁,如果申请锁失败则阻塞,其他的线程可以申请锁;就算申请锁成功,此时已经进入了临界区,只能由成功申请锁的该线程去申请信号量,如果申请信号量成功也还好,这个线程可以继续执行,如果申请信号量失败,不仅这个申请到锁的线程在阻塞等待信号量,其他未申请到锁的线程也在当地。与先申请信号量相比:线程们都可以申请信号量,之后由某一个申请到信号量又申请到锁的去执行临近区代码;而先加锁,一旦加锁后只能有一个线程执行临界区,其余的线程只能等他执行完才能得到调度。总结:先申请信号量可以先进行信号量的申请,一旦得到调度就可以申请锁继续后续动作。
锁–条件变量–信号量 的产生由来
多线程访问共享资源==》线程安全问题~~》使用互斥锁解决安全问题
在安全的基础上存在忙等待和饥饿问题==》使用条件变量解决因不知道资源是否就绪导致的这两个问题
在安全的基础上存在忙等待和饥饿问题==》使用信号量在非临界区预定资源
总结
- 用条件变量配合互斥锁实现互斥与同步:申请锁 -> 判断资源是否就绪+后续访问资源 -> 释放锁。这样做的原因是我们不清楚临界资源的情况/状态
- 信号量提前预订了资源,通过计算机软硬件给我们提供的原子性pv接口,我们可以在非临界区知晓临界资源的情况,即,我们不再考虑资源是否就绪了!即,条件变量解决的因资源就绪而循环申请/释放锁的情况,信号量解决的是压根就不用再考虑资源是否就绪!
- 信号量本质是一把计数器,使得线程可以不用进入临界区就可以得知资源情况==》减少临界区内部的判断!
- 信号量:如果你需要控制对有限资源的访问(如线程池、限制并发用户等),信号量可能更适合。
- 条件变量:如果你的线程需要在某个条件满足之前等待(如生产者-消费者问题),条件变量通常是更好的选择。
可重入和线程安全
可重入函数 ⇒ 线程安全。
线程安全不一定是可重入的:如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数锁还未释放则会产生死锁,因此是不可重入的【示例如下,即线程只使用一个锁的死锁现象】
线程相关问题
在两个线程中同时执行f1和f2,待两个函数都返回后,a的所有可能值是哪些?
【待两个函数都返回后】:并不一定是两个函数都执行了,时间片用完,也是返回;
A执行1,切换,B执行34,切换,A执行2,时间片结束,a=4;
A执行3,切换,B执行12,切换,A执行4,时间片结束,a=13;
A执行1234,时间片结束,a=15;
A执行34,切换,B执行12,时间片结束,a=26;
线程池
回顾进程池
// g++ test.cc -o test -std=c++11#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <cassert>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include "Task.hpp"
using namespace std;#define PROCESS_NUM 5typedef pair<pid_t, int> slot;void sendCommand(int fatherWriter, uint32_t command_ref)
{// ssize_t write(int __fd, const void *__buf, size_t __n)write(fatherWriter, &command_ref, sizeof(command_ref));
}int getCommand(int childReader, bool &quit)
{uint32_t command_ref = 0;// ssize_t read(int __fd, void *__buf, size_t __nbytes)ssize_t s = read(childReader, &command_ref, sizeof(command_ref));if (s == 0){quit = true;return -1;}assert(s == sizeof(uint32_t));return command_ref;
}int main()
{Load();vector<pair<pid_t, int>> slots;for (int i = 0; i < PROCESS_NUM; i++){int pipefd[2] = {0};int n = pipe(pipefd);assert(n == 0);(void)n;pid_t id = fork();assert(id != -1);if (id == 0){close(pipefd[1]);while (true){bool quit = false;int command_ref = getCommand(pipefd[0], quit);if (quit == true)break;if (command_ref >= 0 && command_ref < cmdSetsize())cmdSet[command_ref]();elsecout << "非法command: " << command_ref << endl;}exit(1);}close(pipefd[0]);slots.push_back(pair<pid_t, int>(id, pipefd[1]));}srand((unsigned long)time(nullptr) ^ getpid() ^ 23323123123L);while (true){int command_ref = rand() % cmdSetsize();int child_ref = rand() % slots.size();sendCommand(slots[child_ref].second, command_ref);cout << "father[" << getpid() << "] call child[" << slots[child_ref].first<< "] execute " << cmdContent[command_ref]<< " through fatherWriter " << slots[child_ref].second << endl;sleep(1);}for (const auto &slot : slots)close(slot.second);for (const auto &slot : slots)waitpid(slot.first, nullptr, WNOHANG);
}