目录
多线程模型-非阻塞IO+one loop per thread
one loop per thread
线程池
one loop per thread与线程池结合
目前主流多线程模型
Reactor模式+线程池
Proactor模式
Master-Worker模型
多线程编程的实现
线程抢占问题
Happens-Before关系
Linux下多线程编程常用函数
线程的创建
线程销毁
多线程下的I/O
RAII与文件描述符管理
RAII与fork()
多线程与信号问题
该设计模式核心就是一个线程一个Reactor,专门用于读写和定时任务。在该模式下,可以用来管理I/O操作(读取、写入、定时任务)
多线程运行中,对于事件循环要求最高的就是线程安全,需要确保线程之间的数据和任务不会相互干扰
该模式的优点
- 固定线程数:在程序开始运行的时候就创建线程,不用频繁的创建和销毁线程,这样就减少性能消耗,同事避免了线程管理的复杂性
- 负载均衡:方便不同线程之间进行负载均衡
- 线程安全:事件发生在线程中处理,不需要考虑事件并发的问题
如果针对一个仅仅只有I/O操作和计算任务的场景,通过阻塞队列可以确保其线程安全的处理任务。
线程池和任务队列相互配合,即首先将任务先放入到任务队列中,然后线程池中的线程从任务队列中拿取任务后进行处理。
- 任务队列:可以通过function定义一个任务类型,然后将这些任务放到一个阻塞队列中,线程池中的每个线程都会从这个队列中获取任务执行
- 线程池:可以借助生产者消费模型来实现一个高效的线程池
设计多线程服务器的时候,通过一个线程一个Reactor的模式,然后与线程吃中任务队列相结合,可以实现在高并发场景下的服务器执行效率。
- 事件循环:I/O循环,主要负责处理I/O多路复用、非阻塞I/O以及定时任务,这部分着重处理的就是I/O任务,可以通过epoll机制去同时监听多个时间,然后处理网络请求
- 线程池:专门负责计算任务,通过I/O与计算任务的分离,从而让CPU的资源更加充分的利用,同时避免线程频繁创建爱和销毁带来的开销
该模式的核心思想如前文所述,即是通过一个或者多个事件循环来监听和分发I/O时间,每个EventLoop都是独立工作的,线程池则主要负责处理非I/O密集型任务,例如需要大量计算或者数据处理任务。
目前Nginx以及Node.js都是基于该模式构建
实现流程分析
- Reactor复杂监听网络连接的I/O事件,当有文件描述符就绪的时候,旧将事件分发给事件处理器中(Channel,预先已经绑定好了回调函数)
- 事件处理类负责该网络连接请求的具体操作,它们也可以将数据或者任务推送到线程池中进行处理
- 线程池存在的目的,就是处理比较耗时的任务,避免阻塞I/O
优缺点分析
- 优点
- 高并发:非阻塞I/O与事件驱动机制结合,有效提高了服务器的并发性能
- 资源分离:I/O操作与计算任务的分离,从而使得I/O操作不会阻塞线程,这样也就不会影响CPU的计算能力
- 缺点
- 代码复杂,同时需要考虑事件分发与并发问题
该种模式与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则从队列中获取任务然后进行处理。
目前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 lock(mtx); // 加锁
if (shared_value == 0) {
// 进行一些操作
shared_value = 1;
}
} // 释放锁
该关系用来表示事件的因果关系,例如如果事件A发生在事件B之前,那么就意味着事件B可以看到A的结果,如果这种因果关系不成立,就不可以保证事件的顺序,会导致不确定的执行结果。
如果想要保证事件之间的因果关系,就要对线程之间进行同步操作,比如可以利用锁或者条件变量同步机制来实现。
std::atomic flag(false);
void threadA() {
flag.store(true, std::memory_order_release); // 设置flag并确保先执行
}
void threadB() {
while (!flag.load(std::memory_order_acquire)) {
// 等待flag被设置
}
// 安全地执行依赖于flag的操作
}
线程的创建与回收
- 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:销毁条件变量
线程创建基本原则分析
目前主流服务器线程管理方法
线程销毁方式
主流服务器的线程销毁方式
exit(3)在多线程程序中是不安全的,因为它会让进程立即终止,不会考虑其他线程是否已经完成任务,这样很容易导致内存泄漏、数据损坏以及死锁的等问题
#include
#include
#include
#include
std::ofstream file;
std::mutex mtx;
void writeToFile() {
std::lock_guard 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 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;
}
多线程操作统一个Socket文件描述符可能会导致逻辑错误和数据不一致问题
事件驱动模型解决文件描述符在多线程环境下数据不一致情况
例如mudou网络库,通过将所有的I/O操作都封装在一个事件循环中,由单线程处理,从而确保I/O操作的顺序性和一致性
RAII模式管理文件描述符
RAII管理文件描述符代码事例
#include
#include // for close()
#include // for socket()
#include // for sockaddr_in
#include // 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在多线程环境下的使用
两者冲突:子进程会复制父进程的整个地址空间和文件描述符,但是RAII管理的资源并不是完全适用于子进程的
#include
#include // 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创建子进程,其内部存储空间是无法控制的
总结:不要在多线程下使用信号,把握不住