目录
多线程模型-非阻塞IO+one loop per thread
多线程模型-非阻塞IO+one loop per thread
one loop per thread
该设计模式核心就是一个线程一个Reactor,专门用于读写和定时任务。在该模式下,可以用来管理I/O操作(读取、写入、定时任务)
多线程运行中,对于事件循环要求最高的就是线程安全,需要确保线程之间的数据和任务不会相互干扰
该模式的优点
- 固定线程数:在程序开始运行的时候就创建线程,不用频繁的创建和销毁线程,这样就减少性能消耗,同事避免了线程管理的复杂性
- 负载均衡:方便不同线程之间进行负载均衡
- 线程安全:事件发生在线程中处理,不需要考虑事件并发的问题
线程池
如果针对一个仅仅只有I/O操作和计算任务的场景,通过阻塞队列可以确保其线程安全的处理任务。
线程池和任务队列相互配合,即首先将任务先放入到任务队列中,然后线程池中的线程从任务队列中拿取任务后进行处理。
- 任务队列:可以通过function定义一个任务类型,然后将这些任务放到一个阻塞队列中,线程池中的每个线程都会从这个队列中获取任务执行
- 线程池:可以借助生产者消费模型来实现一个高效的线程池
one loop per thread与线程池结合
设计多线程服务器的时候,通过一个线程一个Reactor的模式,然后与线程吃中任务队列相结合,可以实现在高并发场景下的服务器执行效率。
- 事件循环:I/O循环,主要负责处理I/O多路复用、非阻塞I/O以及定时任务,这部分着重处理的就是I/O任务,可以通过epoll机制去同时监听多个时间,然后处理网络请求
- 线程池:专门负责计算任务,通过I/O与计算任务的分离,从而让CPU的资源更加充分的利用,同时避免线程频繁创建爱和销毁带来的开销
目前主流多线程模型
Reactor模式+线程池
该模式的核心思想如前文所述,即是通过一个或者多个事件循环来监听和分发I/O时间,每个EventLoop都是独立工作的,线程池则主要负责处理非I/O密集型任务,例如需要大量计算或者数据处理任务。
目前Nginx以及Node.js都是基于该模式构建
实现流程分析
- Reactor复杂监听网络连接的I/O事件,当有文件描述符就绪的时候,旧将事件分发给事件处理器中(Channel,预先已经绑定好了回调函数)
- 事件处理类负责该网络连接请求的具体操作,它们也可以将数据或者任务推送到线程池中进行处理
- 线程池存在的目的,就是处理比较耗时的任务,避免阻塞I/O
优缺点分析
- 优点
- 高并发:非阻塞I/O与事件驱动机制结合,有效提高了服务器的并发性能
- 资源分离:I/O操作与计算任务的分离,从而使得I/O操作不会阻塞线程,这样也就不会影响CPU的计算能力
- 缺点
- 代码复杂,同时需要考虑事件分发与并发问题
Proactor模式
该种模式与Reactor则不相同,Proactor是一种处理异步I/O的操作,它会将I/O操作交给操作系统异步完成,当I/O操作完成后,再通知应用程序进一步处理。
windows上运行的IOCP即使该种模式实现
实现
- 应用程序发起I/O请求,然后返回
- 操作系统后台完成I/O操作,并在完成后通知应用程序
- 应用程序在收到通知后处理返回的结果
优缺点
- 优点
- 减少阻塞:因为所有的I/O操作都是操作系统异步完成的,所以应用程序根本不需要等待
- 适用复杂的I/O操作:让操作系统管理I/O,从而使得程序的复杂性降低,耗时比较大的I/O操作非常适用于该模式
- 缺点
- 对操作系统具有较高的要求
Master-Worker模型
Master线程主要就是负责接收客户端的请求,分配任务;Worker线程则是主要负责具体的任务执行。Master线程会将请求添加到任务队列中,然后Worker则从队列中获取任务然后进行处理。
目前Apache HTTP server就是基于该模式构建
实现
- Master线程负责监听和接收连接请求
- Worker线程从任务队列中获取任务进行处理
- 如果任务处理完毕,Worker线程会返回空闲状态,等待下一次任务分配
优缺点
- 优点
- 职责明确:对线程进行分类,一个负责监听连接另一个专门干活
- 缺点
- Master线程会成为性能瓶颈:如果Master线程的负载过高,会导致性能下降
多线程编程的实现
线程抢占问题
操作系统的调度器可以在任意时刻暂停当前正在执行的线程,并将控制权交给其他线程。但是线程的执行又不是按照顺序的,所以加入A线程正在执行,此时被抢占切换到了线程B的时候,线程A的状态可能就会改变,这种问题在访问全局变量以及共享资源的时候更为突出。在这种切换下可能就会引起数据竞争的问题。
- 状态不可控:例如线程A正在修改临界资源的一个变量,A线程刚刚读取到还没有修改的时候,被其他线程抢占了,此时就会导致数据不一致的情况
- 崩溃风险:例如A线程此时正在判断一个指针时候有效,此时该指针还没有上锁,但是此时线程B抢占了该线程,B还将这个指针置空了,这样这个指针就成了一个无效指针,所以A线程在恢复的时候就会出现崩溃
解决资源抢占问题,可以通过类似于互斥锁来确保访问共享资源是原子性即可,也就是只要保证在一个线程访问临界资源的时候,其他线程不会访问或者修改资源,这样也就保证了数据一致性。
std::mutex mtx;
int shared_value = 0;
void threadA() {
std::lock_guard<std::mutex> lock(mtx); // 加锁
if (shared_value == 0) {
// 进行一些操作
shared_value = 1;
}
} // 释放锁
Happens-Before关系
该关系用来表示事件的因果关系,例如如果事件A发生在事件B之前,那么就意味着事件B可以看到A的结果,如果这种因果关系不成立,就不可以保证事件的顺序,会导致不确定的执行结果。
如果想要保证事件之间的因果关系,就要对线程之间进行同步操作,比如可以利用锁或者条件变量同步机制来实现。
std::atomic<bool> flag(false);
void threadA() {
flag.store(true, std::memory_order_release); // 设置flag并确保先执行
}
void threadB() {
while (!flag.load(std::memory_order_acquire)) {
// 等待flag被设置
}
// 安全地执行依赖于flag的操作
}
Linux下多线程编程常用函数
线程的创建与回收
- pthread_create:用于创建新线程
- pthread_join:用于等待线程的结束
互斥锁的创建、销毁、加锁与解锁
- pthread_mutex_init:创建互斥锁
- pthread_mutex_lock:加锁互斥锁
- pthread_mutex_unlock:解锁互斥锁
- pthread_mutex_destroy:销毁互斥锁
条件变量的使用
- pthread_cond_init:初始化条件变量
- pthread_cond_wait:等待条件变量
- pthread_cond_signal:通知某个线程条件满足
- pthread_cond_broadcast:通知所有线程条件满足
- pthread_cond_destroy:销毁条件变量
线程的创建
线程创建基本原则分析
- 不允许随便创建线程
- 线程库中的函数不可以使用不经过告知情况下就创建自己的后台线程,这样会导致资源不可控,在每次创建线程的时候,都要明确该线程的使用目的
- main()函数之前不可以启动线程
- 避免全局对象初始化的时候与线程创建的时候发生冲突
- 资源分配要在初始化阶段完成
- 线程的创建和资源分配都要在初始化的时候就应该完成,这样就可以避免程序运行期间过程中动态频繁地创建和销毁线程,从而减少系统负载
- 线程的数量要与CPU的资源相匹配
- 也就是说,线程的创建数目要和内核数紧密关联,避免大量的线程创建导致服务器性能下降的情况
- 避免频繁的创建和销毁线程
- 因为频繁的创建和销毁线程会导致CPU资源的浪费以及上下文切换开销。该问题可以通过线程池有效解决
目前主流服务器线程管理方法
- 事件驱动+线程池
- 每个线程都管理多个连接,复杂的运算交给线程池中的线程,线程池中的数量通常与物理CPU核数相匹配的,避免资源竞争
- 基于CPU核数进行线程调度
- 服务器的线程数应该和CPU核心数量保持一致,线程数一般是不可以超过CPU核数,这样才可以有效的利用多核性能,不会因为过多线程争夺CPU时间片而导致资源浪费
- 线程池复用机制
- 也就是提前创建好一批线程来处理并发请求,这样就避免了每次请求到来的时候都需要创建一个新线程
- 避免线程Thrashing
- 如果服务器在高负载情况下,会导致该情况,也就是CPU不停的切换线程,会影响执行任务的时间
线程销毁
线程销毁方式
- 自然死亡
- 最安全的线程销毁模式,线程执行完成其主函数后,线程将自动退出,并释放所有资源,也不需要的去调用线程销毁函数
- 非正常死亡
- 线程运行的时候触发的异常或者执行了非法的行为,导致线程异常退出,该种情况下,线程的资源可能没有正确的释放,严重点会导致系统的崩溃
- 自杀
- 也就是线程显式的终止自身,通过调用pthread_exit()
- 他杀
- 线程被外部强制终止,该种情况可能会导致线程无法正常释放资源,或者导致共享资源的不一致
主流服务器的线程销毁方式
- 线程池机制
- 例如现在的Nginx、Apache、Tomcat等服务器,都是使用线程池来管理线程的创建和销毁的。利用线程池减少减少创建和销毁的性能开销,同时合理控制线程数量可以有效避免线程资源浪费
- Nginx等服务器也会通过事件驱动模型以及非阻塞IO来减少线程的使用,同时结合执行任务的时候从线程池中拿去线程,使用后再归还回去
- 优点
- 避免频繁的线程创建和销毁
- 降低系统负载,减少上下文切换的开销
- 通过固定数量的线程控制,避免线程过多导致了资源争夺
- RAII模式
- mudou库就是一种使用RAII模式来管理线程的生命周期,RAII通过封装线程和创建销毁操作,确保线程对象的生命周期结束的时候,所有资源都可以得到自动释放,这样可以有效的防止线程泄漏
- 优点
- 系统管理更加自动化和安全;减少了线程管理的复杂度
- Graceful Shutdown
- 服务器关闭或者重启的时候会采用优雅关闭的机制,也就是等待所有正在处理的请求都执行完毕后,才逐步销毁线程。通过该机制可以确保所有的请求都得到了完全的处理,同时避免强制终止线程带来的不一致性的问题
- 服务器Apache HTTP Server和Tomcat就是在关闭的时候会触发Graceful Shutdown优雅的关闭正在执行的任务,等待所有的线程都执行完毕后再进行销毁操作;
- Nginx也采用的类似的方法,通过发送信号通知worker进程优雅退出,避免中途强制终止线程
- 优点
- 确保未处理完成的请求不会被强制终止
exit(3)在多线程程序中是不安全的,因为它会让进程立即终止,不会考虑其他线程是否已经完成任务,这样很容易导致内存泄漏、数据损坏以及死锁的等问题
- 事例说明
- 假设线程1专门负责向文件中写入数据,线程2则是在执行函数1秒后,强制退出程序
- 当线程2调用的时候,程序立即退出,不会等待线程1完成其文件操作的,这样必然会导致问题
- 文件可能没有正常关闭:线程1仍然在操作文件,但是因为exit的调用,文件操作被终止了,这样就会导致文件没有被正确的关闭,文件可能损坏
- 锁未释放:线程1持有的互斥锁没有被释放,这样就有可能导致死锁的问题
#include <iostream>
#include <fstream>
#include <thread>
#include <mutex>
std::ofstream file;
std::mutex mtx;
void writeToFile() {
std::lock_guard<std::mutex> guard(mtx);
file.open("example.txt");
if (file.is_open()) {
file << "Writing some data..." << std::endl;
}
std::this_thread::sleep_for(std::chrono::seconds(5)); // 模拟长时间操作
file.close();
}
void exitProgram() {
std::this_thread::sleep_for(std::chrono::seconds(1)); // 延迟1秒
std::cout << "Exiting program now..." << std::endl;
exit(1); // 立即退出程序
}
int main() {
std::thread t1(writeToFile);
std::thread t2(exitProgram);
t1.join();
t2.join();
return 0;
}
解决退出线程安全问题:可以利用表示变量,建立一个全局的标志变量指示程序是否应该退出;同时使用pthread_join对资源进行回收
bool shouldExit = false;
void exitProgramSafely() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::lock_guard<std::mutex> guard(mtx);
shouldExit = true;
std::cout << "Setting exit flag..." << std::endl;
}
int main() {
std::thread t1(writeToFile);
std::thread t2(exitProgramSafely);
t1.join();
t2.join();
return 0;
}
多线程下的I/O
多线程操作统一个Socket文件描述符可能会导致逻辑错误和数据不一致问题
- 同步I/O与文件描述符的线程安全性
- 操作系统调用本身是线程安全的,所以不需要担心多个线程操作同一个Socket而导致系统崩溃和内核崩溃
- 多个线程操作同一个Socket会导致逻辑上的混乱
- 一个线程阻塞读取Socket数据,另一个线程关闭了该Socket,就可能会导致读取失败
- 一个线程正在accept新连接的时候,另一个线程关闭了该监听Socket的时候,也会导致逻辑错误
- 多个线程对同一个Socket进行Read操作的时候,读取的数据可能会出现混乱,难以将收到的数据拼成正确的消息
- 文件描述符的完整性
- socket的读取和写入时没有办法保证数据的完整性的
- 写操作同理,多线程可能会将数据交叉的写入到同一个socket中,导致远程接收方收到的数据是混乱的
事件驱动模型解决文件描述符在多线程环境下数据不一致情况
例如mudou网络库,通过将所有的I/O操作都封装在一个事件循环中,由单线程处理,从而确保I/O操作的顺序性和一致性
RAII与文件描述符管理
RAII模式管理文件描述符
- 核心思想
- 资源的申请和释放都绑定到对象的生命周期:对象创建的时候获取资源(该处获取文件描述符),销毁的时候则自动释放资源
- 通过RAII封装socket,可以实现当对象的生命周期结束的时候,文件描述符会自动关闭,避免了手动管理文件描述符
- 优势
- 借助RAII,在编写程序的时候,不需要担心文件描述符什么时候关闭,所以的资源释放都是由对象的生命周期自动管理的,避免了资源泄漏
- 也简化的编程
RAII管理文件描述符代码事例
#include <iostream>
#include <unistd.h> // for close()
#include <sys/socket.h> // for socket()
#include <netinet/in.h> // for sockaddr_in
#include <memory> // for shared_ptr
// 封装文件描述符的类,使用 RAII 方式管理
class SocketRAII {
public:
explicit SocketRAII(int fd) : fd_(fd) {}
// 析构函数:对象销毁时自动关闭文件描述符
~SocketRAII() {
if (fd_ != -1) {
std::cout << "Closing socket " << fd_ << std::endl;
close(fd_);
}
}
int get() const { return fd_; } // 获取文件描述符
private:
int fd_;
};
// 模拟处理 socket 的函数
void handleConnection(int socket_fd) {
// 使用 RAII 封装 socket 文件描述符,确保函数结束时自动关闭
SocketRAII socket(socket_fd);
// 模拟一些 socket 操作
std::cout << "Handling connection on socket " << socket_fd << std::endl;
// 离开函数时,RAII 对象会自动调用析构函数关闭 socket
}
int main() {
// 创建一个模拟的 socket
int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if (socket_fd == -1) {
std::cerr << "Failed to create socket" << std::endl;
return -1;
}
// 使用 RAII 管理 socket,处理连接
handleConnection(socket_fd);
// socket 在 handleConnection 函数中已自动关闭
std::cout << "Socket has been automatically closed by RAII." << std::endl;
return 0;
}
RAII在多线程环境下的使用
- 典型问题
- 当客户端连接的时候,会自动的给每一个客户端连接都分配一个socket文件描述符
- 服务端的每个线程则是负责处理一个客户端连接,当请求处理完毕后,连接可能会断开,此时socket文件描述符也会关闭
- 但是关闭前,这个socket可能会被其他线程错误的复用,这样也就会导致部分错误,也就造成了多线程下文件描述符不安全
- 解决
- 通过RAII机制,让多个线程同时访问同一个文件描述符的时候,通过自动管理资源机制,从而减少竞争
- 高并发服务器Nginx中就大量的使用的RAII思想,其内部主要是通过事件驱动模型和epoll机制管理这些文件描述符;与此同时借助类似于RAII的方式,确保每个文件描述符都可以在执行完毕后安全释放
RAII与fork()
两者冲突:子进程会复制父进程的整个地址空间和文件描述符,但是RAII管理的资源并不是完全适用于子进程的
- 父进程中某个封装在RAII的对象在析构函数中被释放的,子进程继承该文件描述,无法与父进程同步,这就会导致资源管理混乱
- 因为RAII机制是在对象生命周期到后自动释放资源,那么当父子进程都释放同一个对象的时候,自然会导致错误
#include <iostream>
#include <unistd.h> // for fork()
class Foo {
public:
Foo() {
std::cout << "Foo 构造函数" << std::endl;
}
~Foo() {
std::cout << "Foo 析构函数" << std::endl;
}
void doIt() {
std::cout << "在子进程和父进程中执行 doIt" << std::endl;
}
};
int main() {
Foo foo; // 调用构造函数
if (fork() == 0) {
// 子进程中
foo.doIt(); // 子进程调用方法
} else {
// 父进程中
foo.doIt(); // 父进程调用方法
}
// 在父进程和子进程中,Foo 对象会析构一次
}
总结:使用了RAII机制,就不要去使用fork创建子进程,其内部存储空间是无法控制的
多线程与信号问题
总结:不要在多线程下使用信号,把握不住
- 多线程下信号的复杂性
- 信号发出后可能会被发送到某个线程中,也可能需要屏蔽某些信号(例如SIGSEGV泽这个信号就需要屏蔽,防止进程崩溃)
- 信号处理函数中不允许调用任何与线程相关函数,尤其是有条件变量和锁的函数,这些都可能会导致信号处理的过程中的死锁问题
- 信号发出的时候,不一定是主线程进行处理;多线程环境下,如果某个线程屏蔽了一个信号,但是其他信号需要该信号?