目录
线程与进程
进程
进程是指一个程序的运行实例,每个进程都有自己唯一的标识:进程 ID,也有自己的生命周期。进程都有父进程,父进程也有父进程,从而形成了一个以 init 进程 (PID = 1)为根的家族树。
几类特殊进程
- 孤儿进程:定义:孤儿进程是指父进程在子进程终止之前就结束了,子进程在父进程结束后仍然存在。特点:孤儿进程没有父进程,它们会被init进程(在Linux中通常是进程号为1的进程)接管,并由init进程负责回收资源。影响:孤儿进程本身不会对系统造成危害,因为它们最终会被init进程回收。孤儿进程的存在是系统正常工作的一个环节,确保了即使父进程异常退出,子进程也能得到妥善处理。
在操作系统中,通常情况下,父进程结束时,它创建的子进程也会结束。这是因为父进程在创建子进程时,会为子进程分配资源,并且子进程通常会继承父进程的一些状态和资源。当父进程执行到结束点(例如执行完所有代码或调用exit()函数)并退出时,它释放了为子进程分配的资源,并且操作系统通常会清理子进程,使其结束。
然而,也有一些例外情况:
- 子进程在父进程结束前完成自身任务:如果子进程在父进程结束之前就已经完成了自己的任务(例如读取文件、执行计算等),那么它会正常结束,与父进程的结束无关。
- 父进程可以通过调用wait()或waitpid()函数来等待子进程结束。调用这些函数后,父进程会阻塞,直到一个等待的子进程结束。
- 子进程调用exit()或_exit()函数:如果子进程主动调用exit()或_exit()函数结束自身,那么它会立即结束,与父进程的结束无关。
- 多线程或多进程环境:在多线程或多进程的环境中,子进程和父进程之间可能有更复杂的交互,但通常情况下,父进程的结束会导致子进程结束,除非有特殊的编程逻辑来控制子进程的生命周期。
-
僵尸进程:定义:僵尸进程是指已经完成任务但未被父进程回收的进程。它们已经结束执行,但仍然保留在进程表中,等待父进程读取其退出状态代码。
特点:僵尸进程不再执行任何操作,它们只保留了一个进程描述符(PCB)在系统中,用于保存退出状态信息。僵尸进程不会占用CPU资源,但会占用进程表中的空间。影响:僵尸进程不会直接危害系统运行,但如果大量存在,可能会耗尽进程表空间,导致系统无法创建新的进程。此外,僵尸进程的存在可能表明父进程有错误,没有正确处理子进程的退出。 -
守护进程:是一种在后台执行的程序。独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。
进程回收
为避免僵尸进程的产生,一般我们会在父进程中进行子进程的资源回收,回收方式有两种,一种是阻塞方式wait(),一种是非阻塞方式waitpid()。
wait阻塞函数,如果没有子进程退出, 函数会一直阻塞等待, 当检测到子进程退出了, 该函数阻塞解除回收子进程资源。这个函数被调用一次, 只能回收一个子进程的资源,如果有多个子进程需要资源回收, 函数需要被调用多次。
会话与进程组
一个会话包含多个进程组,一个进程组包含多个进程,一个进程包含多个线程。
线程
线程是程序的最小执行单位,是操作系统调度的基本单位
进程与线程的区别
- 一个程序有且只有一个进程,但可以拥有至少一个的线程,多个线程可以并发
- 进程有自己独立的地址空间, 多个线程共用同一个虚拟地址空间
- 进程是操作系统中最小的资源分配单位,线程是程序的最小执行单位
- 一个进程分配一个虚拟地址空间(虚拟地址空间认为是连续的,但是实际上在物理内存不同位置),一个进程只能抢一个CPU时间片;如果将空间划分为多个线程,各线程根据操作系统的调度使用CPU时间片
- 线程更加廉价, 启动速度更快, 退出也快, 对系统资源的冲击小。
几个线程(进程)调度方法:
先来先服务
短作业优先
循环轮转
根据线程(进程)的优先级调度
进程和线程的生命周期
进程生命周期
- 创建态:进程被创建,但尚未执行
- 就绪态:可以抢CPU时间片但是还没运行
- 运行态:抢cpu资源
- 阻塞态:进程被强制放弃CPU,并且没有抢夺CPU时间片的资格,比如等待某个事件发生(如I/O操作)
- 退出态: 进程被销毁, 占用的系统资源被释放了
进程状态的转换通常是由操作系统来管理的,比如进程调度器会根据进程的状态来分配CPU时间片
线程的生命周期
- 新建(New):线程对象被创建,但是还没有调用start()方法。
- 就绪(Runnable):线程已经准备好执行,等待CPU时间片。
- 运行(Running):线程正在执行。
- 阻塞(Blocked):线程暂时停止执行,等待监视器锁。
- 等待(Waiting):线程进入无限期等待,直到其他线程通知它继续执行。
- 计时等待(Timed Waiting):线程进入限期等待,一段时间后自动恢复。
- 终止(Terminated):线程完成了执行或遇到return语句或异常退出。
线程状态的转换通常是通过线程自身的方法来实现的,比如sleep(), wait(), join(), yield()等。
在多线程环境中,线程通常共享同一个进程的CPU时间片,操作系统会将时间片分配给进程中的所有线程,线程之间会根据优先级和调度策略来竞争时间片。
同一进程的线程共享什么资源
共享:
- 堆,堆是进程空间开辟出来的
- 全局变量和静态变量
- 文件等公共资源
独享: - 栈,栈是线程独享的
- 寄存器
线程的个数
处理多任务程序的时候使用多线程比使用多进程要更有优势,但是线程并不是越多越好
-
文件IO操作:文件IO对CPU是使用率不高, 因此可以分时复用CPU时间片, 线程的个数 = 2 * CPU核心数 (效率最高)
-
处理复杂的算法(主要是CPU进行运算, 压力大),线程的个数 = CPU的核心数 (效率最高)
进程之间和线程之间各自的通信方式
进程间通信
可以分为同一台机器的进程之间通信,不同机器之间进程通信
- 同一台主机进程间通信
-
共享文件(比如一个进程创建和写入一个文件,然后另一个进程从这个相同的文件中进行读取);
-
共享内存(要使用信号量) 让进程和共享内存进行关联,得到共享内存的起始地址之后就可以直接进行读写操作了。
(在任何时候当共享内存进入一个写入者场景时,无论是多进程还是多线程,都有遇到基于内存的竞争条件的风险,所以,需要引入信号量来协调(同步)对共享内存的获取); -
管道(无名管道、命名管道);管道的本质其实就是内核中的一块内存(或者叫内核缓冲区),这块缓冲区中的数据存储在一个环形队列中
管道对应的内核缓冲区大小是固定的
管道分为两部分:读端和写端(队列的两端),数据从写端进入管道,从读端流出管道。
管道中的数据只能读一次,做一次读操作之后数据也就没有了(读数据相当于出队列)。
管道是单工的:数据只能单向流动, 数据从写端流向读端。
对管道的操作(读、写)默认是阻塞的
读管道:管道中没有数据,读操作被阻塞,当管道中有数据之后阻塞才能解除
写管道:管道被写满了,写数据的操作被阻塞,当管道变为不满的状态,写阻塞解除
- 消息队列;
- 套接字(socket)(套接字的两种类型:IPC 套接字(即 Unix 套接字)给予进程在相同设备(主机)上基于通道的通信能力;网络套接字给予进程运行在不同主机的能力,因此也带来了网络通信的能力。网络套接字需要底层协议的支持,例如 TCP(传输控制协议)或 UDP(用户数据报协议);
- 不同机器之间的进程通信
网络 Socket:TCP/IP 或 UDP 协议,用于不同机器间的数据传输。
远程过程调用 (RPC):允许程序调用另一台机器上的方法,就像调用本地方法一样。
HTTP/REST:通过 HTTP 协议进行通信,常用于 Web 服务。
消息中间件:如 RabbitMQ、Kafka 等,支持异步消息传递。
线程间通信:
线程间通信方式
线程间通信是指在同一进程内的多个线程之间交换数据和信息的机制。由于线程共享同一进程的内存空间,因此线程间的通信通常比进程间通信更高效。以下是一些常见的线程间通信方式:
- 共享变量
- 描述:多个线程可以访问共享的全局变量或静态变量。
- 注意事项:需要使用同步机制(如锁、信号量等)来避免数据竞争和不一致性。
- 互斥锁 (Mutex)
- 描述:互斥锁用于保护共享资源,确保同一时刻只有一个线程可以访问该资源。
- 使用场景:适合对临界区的访问控制,防止数据竞争。
- 读写锁 (Read-Write Lock)
- 描述:允许多个线程同时读取共享资源,但写操作是独占的,即在写入时禁止其他线程读取或写入。
- 使用场景:适合读多写少的场景,提高并发性能。
- 条件变量 (Condition Variable)
- 描述:用于线程同步,可以让线程在某个条件满足之前等待,并在条件满足时通知其他线程继续执行。
- 使用场景:适合实现生产者-消费者模型。
- 信号量 (Semaphore)
- 描述:用于控制对共享资源的访问,允许多个线程同时访问资源,但通过计数来限制最大并发访问数量。
- 使用场景:适合用于限流,例如限制连接数。
- 事件 (Event)
- 描述:线程可以等待一个事件被设置,当事件被触发时,等待的线程会被唤醒。
- 使用场景:适合处理状态通知或线程间的简单信号传递。
- 管道 (Pipe)
- 描述:虽然管道主要用于进程间通信,但在某些编程环境中也可以在同一进程内部的线程之间使用。
- 使用场景:适合数据流的传输。
- 消息队列 (Message Queue)
- 描述:允许线程以消息的形式进行通信,提供异步的消息传递机制。
- 使用场景:适合复杂系统中的任务调度和消息处理。
总结
在多线程编程中,选择合适的通信方式非常重要,不同的方式具有不同的特性和适用场景。以下是一个简要总结:
通信方式 | 特点 | 使用场景 |
---|---|---|
共享变量 | 高效,但需注意同步 | 简单数据传输 |
互斥锁 | 防止数据竞争 | 对临界区的访问控制 |
读写锁 | 读多写少时提高并发 | 数据库访问等 |
条件变量 | 线程等待特定条件 | 生产者-消费者模型 |
信号量 | 控制并发访问数量 | 限流或资源管理 |
事件 | 简单的状态通知 | 状态变化的通知 |
管道 | 数据流传输 | 需要顺序传输的场景 |
消息队列 | 异步消息传递 | 任务调度和复杂系统中的消息处理 |
多线程并发
如果使用 多进程 并发(将一个程序划分为多个独立进程),虽然操作系统对进程有保护,相对安全。但是进程间通信时系统开销大,速度慢。所以用多线程并发。
多线程本身就在一个内存空间中,方便通信,但是会遇到死锁问题。
线程不安全
在多线程编程中,"线程不安全"通常指的是在多个线程同时访问和修改共享资源(如变量、对象、文件等)时,可能会导致数据不一致、逻辑错误或程序行为不可预测的问题。这种不安全性主要源于以下几个方面:
竞态条件(Race Condition):当多个线程的执行顺序或时间点影响到共享资源的状态时,就可能发生竞态条件。例如,如果两个线程同时读取一个变量的值,然后基于这个值进行修改,那么最终的结果将取决于线程的执行顺序,这可能导致不一致的状态。
原子性缺失:原子操作是指不可被中断的一个或一系列操作。如果一个操作不是原子的,那么在多线程环境中,这个操作可能会被其他线程中断,导致数据损坏或不一致。
可见性问题:在多线程环境中,一个线程对共享变量的修改可能不会立即被其他线程看到,这可能导致其他线程基于过时的数据做出错误的决策。
死锁和活锁:线程不安全的代码可能导致线程间的死锁(两个或多个线程互相等待对方释放资源)或活锁(线程不断改变自己的状态以响应其他线程,但没有任何进展)。
线程同步
线程同步是什么
有多个线程对内存中的共享资源进行访问的时候,要按照先后顺序依次进行,这就是线程同步
线程同步是实现线程安全的一种手段,但并不是所有线程同步的代码都是线程安全的。线程安全的代码需要综合考虑同步机制、代码逻辑、数据访问模式等多个方面。只有当所有这些因素都被正确处理时,代码才能被认为是线程安全的。
同步方式
对于多个线程访问共享资源出现数据混乱的问题,需要进行线程同步。常用的线程同步方式有四种:互斥锁、读写锁、条件变量、信号量。
所谓的共享资源就是多个线程共同访问的变量,这些变量通常为全局数据区变量或者堆区变量,这些变量对应的共享资源也被称之为临界资源。
互斥锁:通过互斥锁可以锁定一个代码块, 被锁定的这个代码块, 所有的线程只能按顺序执行
某个线程执行部分代码块后,cpu时间片到期,线程从运行态转变为就绪态,这时候其他线程运行,其他线程不能访问共享资源(代码块是对共享资源的操作区域),直到之前的线程执行完毕,别的线程才能访问共享资源。
读写锁:读写锁是互斥锁的升级版, 在做读操作的时候可以提高程序的执行效率,如果所有的线程都是做读操作, 那么读是并行的,但是使用互斥锁,读操作是串行的。
自旋锁(SpinLock):自旋锁是一种轻量级锁,在获取锁时不会进入睡眠,而是不断循环检查锁是否可用。这种机制可以减少上下文切换的开销,但如果锁被长时间占用,可能会导致CPU资源浪费。
信号量:一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作。信号量主要阻塞线程, 不能完全保证线程安全,如果要保证线程安全, 需要信号量和互斥锁一起使用
原子变量(Atomic Variable):原子变量是一种支持原子操作的变量,可以用于实现简单的线程同步。原子变量的操作是原子性的,不会被中断,不会失去CPU时间片,因此可以在多线程环境下使用。
几种锁的比较
- 互斥锁 vs 读写锁
互斥锁是最基本的锁机制,适用于所有需要对共享资源进行同步的情况,但它是阻塞式的,不允许并发读操作。
读写锁提供了更精细的控制,允许多个线程并发读取资源,只有在写操作时才会独占资源,因此在读多写少的场景下,读写锁提供了比互斥锁更好的性能。 - 互斥锁 vs 自旋锁
互斥锁通常会导致线程阻塞,特别是在高争用情况下,可能会涉及上下文切换,导致一定的性能损耗。
自旋锁则是忙等待,它避免了阻塞和上下文切换,但在高争用的情况下,自旋锁可能会浪费大量 CPU 时间,因此自旋锁的性能优势通常体现在争用较少、锁持有时间较短的情况。 - 读写锁 vs 自旋锁
读写锁适用于读多写少的场景,能够提高读操作的并发性,但写操作需要独占访问,并且实现上比自旋锁复杂,性能上也较为高。
自旋锁适用于高并发低争用的场景,锁的实现简单,适合锁持有时间非常短的情况。但在争用激烈时性能较差,因为它消耗 CPU 时间进行自旋。
总结
互斥锁:适用于一般情况下的资源互斥访问,但性能上有一定开销,尤其在高争用的场景中。
读写锁:适用于读多写少的场景,能够提高读操作的并发性,但写操作会被阻塞。
自旋锁:适用于低争用、锁持有时间较短的场景,避免了阻塞和上下文切换,但高争用时性能较差。
线程池
为什么需要线程池
如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。
而线程池维护着多个线程,等待监督管理者分配可并行执行的任务。这样避免了在短时间内创建和销毁线程的代价。线程池不仅能够内核的充分利用,还能防止过分调度。
线程池组成
- 任务队列,存储需要处理的任务,由工作的线程来处理这些任务
通过线程池提供的API函数,将一个待处理的任务添加到任务队列,或者从任务队列中删除 - 管理者线程(不处理任务队列中的任务),1个
它的任务是周期性的对任务队列中的任务数量以及处于忙状态的工作线程个数进行检测
当任务过多的时候, 可以适当的创建一些新的工作线程
当任务过少的时候, 可以适当的销毁一些工作的线程 - 工作的线程(任务队列任务的消费者) ,N个
线程池中维护了一定数量的工作线程, 他们的作用是是不停的读任务队列, 从里边取出任务并处理
工作的线程相当于是任务队列的消费者角色,
如果任务队列为空, 工作的线程将会被阻塞 (使用条件变量/信号量阻塞)
如果阻塞之后有了新的任务, 由生产者将阻塞解除, 工作线程开始工作
线程池工作的四种状态
- 主线程没有任务,工作线程空闲,任务队列为空
- 任务数小于等于工作线程数,给主线程添加任务,工作线程获取任务执行,任务队列为空
- 任务数大于等于工作线程数,线程池没有空闲工作线程,将任务存入任务队列。工作线程空闲后,主动从任务队列中获取任务执行。
- 任务数大于等于工作线程数,并且任务队列已满。阻塞主线程,等待任务队列有空的通知。
创建线程thread
#include <iostream>
#include <thread>
using namespace std;
void thread_1()
{
cout << "子线程1" << endl;
}
void thread_2(int x)
{
cout << "x:" << x << endl;
cout << "子线程2" << endl;
}
void thread_3()
{
cout << "子线程3" << endl;
}
int main()
{
thread first(thread_1); // 开启线程,调用:thread_1()
thread second(thread_2, 100); // 开启线程,调用:thread_2(100)
//thread fun_3(thread_3);
std::cout << "主线程\n";
first.join();
second.join();
std::cout << "子线程结束.\n";
//fun_3.detach();
return 0;
}
上面代码运行后
- 由操作系统创建主线程,main()函数
- 主线程创建两个子线程first和second
- 主线程调用first.join(); 和second.join(); 使主线程变成阻塞状态,直到子进程first和second终止
- 主线程恢复执行
join()与detach()的区别
通过 join() 或 detach() 管理线程的生命周期。
join():
join() 函数用于等待线程的执行完成,并阻塞当前线程直到被调用的线程执行完毕。
当调用 join() 函数时,当前线程会等待被调用的线程执行完毕后再继续执行。
线程是在thread对象被定义的时候开始执行的,而不是在调用join()函数时才执行的,调用join()函数只是阻塞等待线程结束并回收资源。
一般情况下,主线程(即创建其他线程的线程)会调用 join() 函数等待其他线程执行完成,以确保程序的正确执行顺序和资源的正确释放。
detach():
detach() 函数用于分离线程,使其成为守护线程(daemon thread),从而使其在后台运行,与主线程独立。
调用 detach() 函数后,当前线程将不再关心被调用的线程的状态,被调用的线程会在后台独立执行,当其执行完毕后自动释放资源。
一旦线程被分离,就无法再通过 join() 函数来等待其执行完毕,也无法再通过 detach() 函数重新将其加入到主线程。
若没有执行join()或detach()的线程在程序结束时会引发异常。
this_thread的方法
this_thread 是 C++11 引入的一部分,用于线程管理。它位于 C++ 标准库中的 std::this_thread 命名空间下,提供了一些与当前线程相关的操作方法。主要目的是方便操作当前线程的属性、状态或执行任务。
- std::this_thread::sleep_for() 当前线程休眠指定的时间
- std::this_thread::sleep_until() 当前线程休眠直到指定时间点
- std::this_thread::yield() 当前线程让出CPU,允许其他线程运行
- std::this_thread::get_id() 获取当前线程的ID
#include <iostream>
#include <thread>
#include <chrono>
void my_thread()
{
std::cout << "Thread " << std::this_thread::get_id() << " start!" << std::endl;
for (int i = 1; i <= 5; i++)
{
std::cout << "Thread " << std::this_thread::get_id() << " running: " << i << std::endl;
std::this_thread::yield(); // 让出当前线程的时间片
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 线程休眠200毫秒
}
std::cout << "Thread " << std::this_thread::get_id() << " end!" << std::endl;
}
int main()
{
std::cout << "Main thread id: " << std::this_thread::get_id() << std::endl;
std::thread t1(my_thread);
std::thread t2(my_thread);
t1.join();
t2.join();
return 0;
}
mutex的lock()与unlock()使用
互斥量被用来保护 print_block 函数中的临界区
lock() 资源上锁
unlock() 资源解锁
#include <iostream>
#include <thread>
#include <mutex> // 互斥量类,用于同步对共享资源的访问,防止多个线程同时访问共享资源
using namespace std;
mutex mtx; // 声明了一个互斥量 mtx
void print_block(int n, char c)
{
mtx.lock();// 锁定互斥量,进入临界区
//临界区是指多个线程共享资源并进行操作的代码段,为了防止竞态条件,需要通过互斥量来确保同一时间只有一个线程可以执行临界区内的代码。
for (int i = 0; i < n; ++i)
{
cout << c;
}
cout << '\n';
mtx.unlock();// 解锁互斥量,退出临界区
}
int main()
{
thread th1(print_block, 50, '*');//线程1:打印*
thread th2(print_block, 50, '$');//线程2:打印$
th1.join();
th2.join();
return 0;
}
lock_guard
用于实现资源的自动加锁和解锁,它是基于RAII(资源获取即初始化)的设计理念,能够确保在作用域结束时自动释放锁资源,避免了手动管理锁的复杂性和可能出现的错误。
std::lock_guard的主要特点如下:
- 自动加锁: 在创建std::lock_guard对象时,会立即对指定的互斥量进行加锁操作。这样可以确保线程在进入作用域后,互斥量已经被锁定,避免了并发访问资源的竞争条件。
- 自动解锁:std::lock_guard对象在作用域结束时,会自动释放互斥量。无论作用域是通过正常的流程结束、异常抛出还是使用return语句提前返回,std::lock_guard都能保证互斥量被正确解锁,避免了资源泄漏和死锁的风险。
- 适用于局部锁定: 由于std::lock_guard是通过栈上的对象实现的,因此适用于在局部范围内锁定互斥量。当超出std::lock_guard对象的作用域时,互斥量会自动解锁,释放控制权。
#include <iostream>
#include <thread>
#include <mutex> // 互斥量类,用于同步对共享资源的访问,防止多个线程同时访问共享资源
using namespace std;
mutex mtx; // 声明了一个互斥量 mtx
void print_block(int n, char c)
{
//mtx.lock();// 锁定互斥量,进入临界区
//临界区是指多个线程共享资源并进行操作的代码段,为了防止竞态条件,需要通过互斥量来确保同一时间只有一个线程可以执行临界区内的代码。
std::lock_guard<std::mutex> lock(mtx); // 加锁互斥量
for (int i = 0; i < n; ++i)
{
cout << c;
}
cout << '\n';
//mtx.unlock();// 解锁互斥量,退出临界区
}
int main()
{
thread th1(print_block, 50, '*');//线程1:打印*
thread th2(print_block, 50, '$');//线程2:打印$
th1.join();
th2.join();
return 0;
}
unique_lock
std::unique_lock对象lock会在创建时自动加锁互斥量,析构时自动解锁互斥量。
可以通过调用lock和unlock函数手动控制加锁和解锁的时机,以实现更灵活的操作。
此外,它还可以与条件变量(std::condition_variable)一起使用,实现更复杂的线程同步和等待机制。
#include <iostream>
#include <thread>
#include <mutex> // 互斥量类,用于同步对共享资源的访问,防止多个线程同时访问共享资源
using namespace std;
mutex mtx; // 声明了一个互斥量 mtx
void print_block(int n, char c)
{
//mtx.lock();// 锁定互斥量,进入临界区
//临界区是指多个线程共享资源并进行操作的代码段,为了防止竞态条件,需要通过互斥量来确保同一时间只有一个线程可以执行临界区内的代码。
std::unique_lock<std::mutex> lock(mtx); // 加锁互斥量
lock.unlock();// 手动解锁互斥量
lock.lock();// 再次加锁互斥量
for (int i = 0; i < n; ++i)
{
cout << c;
}
cout << '\n';
//mtx.unlock();// 解锁互斥量,退出临界区
}
int main()
{
thread th1(print_block, 50, '*');//线程1:打印*
thread th2(print_block, 50, '$');//线程2:打印$
th1.join();
th2.join();
return 0;
}