目录
一、进程与线程
进程与线程的区别:
- 本质区别:进程是资源分配的基本单位,可以说是一个正在运行的程序;线程是操作系统调度的基本单位。
- 所属关系:一个进程可以拥有多个线程,而一个线程属于一个进程。
- 地址空间:进程有独立的虚拟内存空间,而线程共享进程的虚拟地址空间。
- 内存:系统会为每个进程分配独立的内存空间,线程使用的资源来源于所属进程。
- 通信:进程之间通信需要特定的机制,如管道、消息队列等,而线程之间通信更简单高效。
- 调度开销:由于线程共享同一进程的地址空间和其他资源,线程的创建、销毁和切换开销通常比进程小得多,因为不需要创建新的地址空间。进程的创建和销毁通常需要更多的系统资源和时间,因为需要分配和释放独立的地址空间。
- 适用场景:进程适用于需要相互隔离的任务,或者需要更高稳定性的情况下。线程适用于需要更高并发性和更高性能的情况下,例如服务器程序和图形界面应用程序
二、并发简介
并发是指在同一时间段内执行多个任务的能力,这种能力可以通过利用多个处理器或者单个多核处理器来实现。在计算机编程中,实现并发的主要手段包括多进程并发和多线程并发。
1. 多进程并发:
- 多进程并发指的是在操作系统中同时运行多个独立的进程。
- 每个进程有自己独立的地址空间和资源,数据受到保护,进程之间的通信需要使用特定的机制,如管道、消息队列、共享内存等。
- 多进程并发的优点是稳定性高,一个进程的崩溃不会影响其他进程,但通信较为复杂,创建和销毁进程的开销相对较大。
2. 多线程并发:
- 多线程并发指的是在单个进程内部创建多个线程来执行任务。
- 所有线程共享同一个进程的地址空间和其他资源,线程之间的通信更加简单高效,可以直接访问并操作共享数据。
- 多线程并发的优点是启动速度快、轻量级,系统开销小,执行速度快,但需要注意线程间共享数据时可能产生的竞态条件和死锁等问题。
综合来看,多线程并发具有启动速度快、轻量级、系统开销小、执行速度快等优点,因此在许多应用场景下被广泛采用。但同时也需要注意共享数据的保护,避免出现数据竞争和同步问题,以确保程序的正确性和稳定性。在实际应用中,选择合适的并发模型取决于具体的需求和系统环境。
三、多线程并发
1. 线程数的选择
选择多线程的线程数涉及到多个因素,包括但不限于以下几点:
(1)处理器核心数:线程数不宜超过处理器核心数,否则可能会出现线程竞争和性能下降。通常情况下,每个线程需要一个处理器核心来执行,超过处理器核心数的线程可能会引起线程切换和调度开销增加,导致性能下降。
(2)任务特性:线程数应该根据任务的特性和计算密集度来选择。对于计算密集型任务,线程数不宜过多,以免过多的线程竞争导致性能下降;而对于I/O密集型任务,可以适当增加线程数来充分利用I/O等待时间,提高系统的响应速度。
(3)系统资源:线程数不宜超过系统资源的限制,包括内存、文件描述符等资源。如果线程数过多,可能会导致系统资源耗尽,引起系统崩溃或性能下降。
(4)线程之间的依赖关系:线程之间是否存在依赖关系也会影响线程数的选择。如果线程之间存在依赖关系,需要确保线程的顺序执行,此时线程数应该与任务之间的依赖关系相匹配,避免出现数据竞争和死锁等问题。
(5)测试和调优:最好通过实验和性能测试来确定最佳的线程数。可以尝试不同数量的线程,并监测系统的性能指标(如CPU利用率、内存使用情况、响应时间等),选择能够达到最佳性能的线程数。
总的来说,选择多线程的线程数应该根据具体的应用场景和任务特性来确定,并结合系统资源和性能测试进行综合考量,以达到最佳的性能和稳定性。
2. 锁方案
2.1 为什么需要锁?
创建10个线程,每个线程执行10次的 count++
操作,理论上最后 count
应该等于100。然而,由于 count++
不是原子操作,在汇编级别上通常包含三个步骤,见下图。count是临界资源,共享的变量,在多线程环境下,当一个线程执行 count++
中的第一步操作时,另一个线程也可能同时进入执行,这可能导致数据竞争,从而使得实际累加的值低于100。
因此需要使用锁,在count++之前加锁,count++之后解锁,确保同一时刻只有一个线程能够对共享变量进行访问,从而避免了数据竞争的发生。使用互斥锁mutex的C代码如下所示。
#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 10
#define NUM_INCREMENTS 10
int count = 0;
pthread_mutex_t mutex;
void *increment_count(void *arg) {
for (int i = 0; i < NUM_INCREMENTS; ++i) {
pthread_mutex_lock(&mutex); // 加锁
count++;
pthread_mutex_unlock(&mutex); // 解锁
}
pthread_exit(NULL);
}
int main() {
pthread_t threads[NUM_THREADS];
pthread_mutex_init(&mutex, NULL);
for (int i = 0; i < NUM_THREADS; ++i) {
pthread_create(&threads[i], NULL, increment_count, NULL);
}
for (int i = 0; i < NUM_THREADS; ++i) {
pthread_join(threads[i], NULL);
}
pthread_mutex_destroy(&mutex);
printf("Final count: %d\n", count);
return 0;
}
2.2 常见的锁有哪些?特点及应用场景
(1)互斥锁(Mutex):
- 特点:最常见的一种锁,用于保护共享资源,确保同一时刻只有一个线程能够访问共享资源。
- 应用场景:适用于临界区保护,避免多线程并发访问导致的数据竞争和并发访问问题。
(2)自旋锁(Spinlock):
- 特点:在临界区被其他线程占用时,当前线程将自旋等待直到临界区可用。适用于临界区持有时间较短且线程数量不多的情况。
- 应用场景:对于临界区保护,如果临界区持有时间很短,使用自旋锁可以减少线程的上下文切换开销。
(3)读写锁(Read-Write Lock):
- 特点:允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。读操作之间不互斥,写操作与读操作和写操作之间互斥。
- 应用场景:适用于读操作频繁,写操作较少的情况,可以提高并发性和性能。
(4)递归锁(Recursive Lock):
- 特点:允许同一线程对同一个锁进行重复加锁操作,每次加锁都必须对应着相同次数的解锁操作。
- 应用场景:适用于递归函数中需要加锁的场景,避免死锁。
2.3 自动加锁解锁方案
为了避免了忘记解锁或异常导致未解锁的情况,可以使用unique_lock或lock_guard。两者都是 C++ 中用于管理互斥量的 RAII(Resource Acquisition Is Initialization)类,它们都可以自动管理互斥量的加锁和解锁。下面分别介绍它们的特点及区别:
(1)unique_lock:
-
特点:
std::unique_lock
提供了更多的灵活性,可以手动控制加锁和解锁的时机。- 可以在构造函数中选择是否加锁,也可以在构造后随时调用
lock()
和unlock()
函数手动控制锁的状态。 - 可以在不同的作用域中重复加锁和解锁,例如可以在同一函数中的多个代码块内多次加锁和解锁。
- 支持条件变量(
std::condition_variable
)。
-
示例代码:
-
#include <iostream> #include <thread> #include <mutex> std::mutex mtx; int shared_counter = 0; void increment() { std::unique_lock<std::mutex> lock(mtx); // 自动加锁 ++shared_counter; std::cout << "Counter: " << shared_counter << std::endl; lock.unlock(); // 手动解锁 } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); return 0; }
(2)lock_guard:
-
特点:
std::lock_guard
在构造函数中自动加锁,在析构函数中自动解锁,无法手动控制加锁和解锁的时机,因此更简单、更安全。- 不支持条件变量(
std::condition_variable
)。 - 可以使用在构造函数和析构函数需要成对调用的场景中,如函数体中的临界区。
-
示例代码:
-
#include <iostream> #include <thread> #include <mutex> std::mutex mtx; // 定义一个全局的互斥量 int shared_counter = 0; void increment() { std::lock_guard<std::mutex> guard(mtx); // 自动加锁,出了作用域自动解锁 ++shared_counter; std::cout << "Counter: " << shared_counter << std::endl; } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); return 0; }
(3)区别:
- 灵活性:
std::unique_lock
更加灵活,可以手动控制加锁和解锁的时机,而std::lock_guard
在构造函数中自动加锁,在析构函数中自动解锁。 - 使用场景:
std::lock_guard
适用于构造函数和析构函数需要成对调用的场景,更适合简单的加锁解锁操作。而std::unique_lock
则更适用于需要更多灵活性的情况,例如需要手动控制加锁和解锁的时机,或者需要支持条件变量。 - 性能:由于
std::lock_guard
在构造函数中自动加锁,在析构函数中自动解锁,因此通常会比std::unique_lock
更轻量级,性能更好。
2.4 什么是死锁?如何解决死锁?
死锁是指在多线程或多进程系统中,两个或多个线程或进程相互等待对方释放资源而无法继续执行的情况,导致所有线程或进程都无法继续执行下去,形成了死循环,造成系统资源的浪费。
死锁产生的四个必要条件是:
- 互斥条件:至少有一个资源是不可共享的,即在一段时间内只能由一个线程使用。
- 请求与保持条件:线程至少持有一个资源,并且在等待获取其他线程持有的资源。
- 不剥夺条件:线程已经获得的资源在未使用完之前不能被强行剥夺,只能由持有该资源的线程显式释放。
- 循环等待条件:若干线程之间形成一种循环等待资源关系。
为了避免死锁的发生,可以采用以下方法:
- 加锁顺序:规定所有线程获取资源的顺序,使得所有线程按照相同的顺序获取资源,避免产生循环等待条件。
- 资源预分配:尽量减少线程对资源的竞争,提前分配好所需资源。
- 超时放弃:线程在等待资源时设定一个超时时间,如果超过该时间还未获取到资源,则放弃获取,释放已占有的资源,避免长时间等待造成死锁。
- 死锁检测和恢复:定期检测系统中是否存在死锁,一旦检测到死锁,采取相应的措施解除死锁,例如抢占资源、回滚操作等。
通过合理的设计和实现,以及对死锁产生原因的深入理解,可以有效预防和解决死锁问题,保证系统的稳定性和可靠性。
2.5 什么是原子操作、原子变量?特点及应用场景
(1)介绍:
- 原子操作是指在执行过程中不会被中断或干扰的操作,要么全部执行成功,要么全部不执行,确保操作的不可分割性。原子操作通常应用于多线程并发环境中,用于对共享变量进行操作,以确保线程安全性。
- 原子变量是一种特殊类型的变量,它具有原子性操作的特性。在C++11标准中,引入了
<atomic>
头文件,提供了一系列的原子类型,如std::atomic<int>
、std::atomic<bool>
等,这些类型的变量就是原子变量。 - 原子操作通常是通过原子变量来实现的,但并不限于对原子变量的操作。在实践中,原子操作可以针对单个变量、内存位置或内存区域进行,不一定是原子变量。
(2)原子操作的特点:
- 不可分割性:原子操作是不可被中断的单个操作,要么完全执行成功,要么完全不执行。
- 线程安全:原子操作确保了对共享数据的访问是线程安全的,不会出现数据竞争和不一致性。
(3)原子操作的应用场景:
- 对基本数据类型的操作:原子操作常用于对整数、布尔值等基本数据类型进行增加、减少、赋值等操作,确保操作的原子性,避免多线程并发访问时出现竞态条件。
- 自旋锁和互斥锁的实现:原子操作可以用于实现自旋锁和互斥锁等同步机制,保证对临界区的互斥访问。
(4)实例:
#include <iostream>
#include <atomic>
#include <thread>
std::atomic<int> counter(0); // 原子整型变量
void incrementCounter() {
for (int i = 0; i < 10000; ++i) {
counter++; // 原子增加操作
}
}
int main() {
std::thread t1(incrementCounter);
std::thread t2(incrementCounter);
t1.join();
t2.join();
std::cout << "Counter value: " << counter << std::endl;
return 0;
}
在上面的示例中,counter
是一个原子整型变量,它可以确保对其进行增加操作的原子性。在 incrementCounter()
函数中,两个线程同时对 counter
进行增加操作,由于使用了原子操作,因此可以避免数据竞争和不一致性。
2.6 线程间的通信
线程间通信是指在多线程环境下,不同线程之间进行数据交换和同步的过程。常见的线程间通信方式包括共享内存、消息队列、信号量、条件变量等。下面是一个使用条件变量进行线程间通信的简单实例,其中一个线程负责生产数据,另一个线程负责消费数据:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool data_ready = false;
int data = 0;
// 生产者线程函数
void producer() {
// 生产数据
{
std::lock_guard<std::mutex> lock(mtx);
data = 42;
data_ready = true;
}
// 通知消费者数据已经准备好
cv.notify_one();
}
// 消费者线程函数
void consumer() {
// 等待数据准备
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return data_ready; });
}
// 消费数据
std::cout << "Consumed data: " << data << std::endl;
}
int main() {
// 创建生产者线程和消费者线程
std::thread producer_thread(producer);
std::thread consumer_thread(consumer);
// 等待线程执行完成
producer_thread.join();
consumer_thread.join();
return 0;
}
在这个示例中,producer
函数负责生产数据并通知消费者线程,consumer
函数在收到通知后消费数据。通过条件变量 cv
实现了线程间的同步和通信。生产者线程在生产数据后通知消费者线程,消费者线程在收到通知后开始消费数据。
3. 线程池的实现
3.1 什么是线程池?
线程池是一种用于管理和复用线程的编程技术,它包含一组预先创建的线程,用于执行并发任务,以提高性能和资源利用率。
3.2 为什么要使用线程池?作用是什么?
如下图所示,一个客户端就相当于一个线程,若是有十万个客户端访问服务器,是否要创建十万个线程呢?非也,这样的话内存早就爆炸了,因为每一个线程都会占用一定的内存。因此,需要用到线程池技术。
作用:
- 避免线程太多,使得内存耗尽;
- 避免创建与销毁线程的开销;
- 提高系统的并发性能和响应速度;
- 统一管理和监控;
- 任务与执行分离。
3.3 线程池的组成部分
线程池一般包含三个主要部分:
(1)任务队列:用于存储待执行的任务。任务队列可以是一个先进先出(FIFO)队列,也可以是优先级队列或其他形式的队列。当任务到达时,会被添加到任务队列中等待执行。
(2)工作线程队列:用于从任务队列中获取任务并执行。线程池会预先创建一组工作线程,并将它们保存在一个队列中。这些工作线程会循环地从任务队列中取出任务,并执行它们。
(3)管理组件:用于管理线程池的创建、运行与销毁。管理组件负责初始化线程池,监控工作线程的状态,动态调整线程池的大小,以及在需要时销毁线程池。管理组件还可以处理异常情况,例如任务队列满载或工作线程异常退出等。
线程池通过这三个部分协同工作,实现了任务的异步执行和线程资源的复用。它提供了一种高效的并发编程模型,可以有效地管理系统的并发度和资源消耗,提高系统的性能和稳定性。
举个栗子:银行的业务管理系统可以说就是一个线程池。取号机取号办业务的人是任务队列,每个人办理的业务可能都不一样;柜台上待命的柜员是工作线程队列,准备从任务队列中取任务执行;叫号系统是管理组件,避免出现多个办业务的人在一个柜员处办业务或两个柜员同时为一个人办业务的情况,确保一个柜员只为一个人办理业务。
3.4 线程池实例分析
线程池主要包括四个主要函数
(1)工作线程的入口函数:函数是一个while循环,循环里面加条件判断,若任务队列为空,则使用条件变量的wait等待被唤醒,此时为阻塞状态。若任务队列不为空,则取出一个任务执行。
(2)任务添加函数:这个函数用于将任务添加到任务队列中,并使用条件变量cond的notify唤醒线程。
(3)创建线程池函数:用于创建线程池和一些初始化工作,并指定线程的数量。
(4)销毁线程池函数:这个函数会中止线程池中所有的工作线程,释放资源,并清空任务队列。
代码示例(C语言):
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
//链表的插入
#define LIST_INSERT(item, list) do { \
item->prev = NULL; \
item->next = list; \
if ((list) != NULL) (list)->prev = item; \
(list) = item; \
} while(0)
//链表元素的删除
#define LIST_REMOVE(item, list) do { \
if (item->prev != NULL) item->prev->next = item->next; \
if (item->next != NULL) item->next->prev = item->prev; \
if (list == item) list = item->next; \
item->prev = item->next = NULL; \
} while(0)
//任务队列结构体
struct nTask {
void (*task_func)(struct nTask *task); //任务函数指针
void *user_data; //用户数据指针
struct nTask *prev;
struct nTask *next;
};
//工作线程队列结构体
struct nWorker {
pthread_t threadid; //线程ID
int terminate; //中止标志
struct nManager *manager;
struct nWorker *prev;
struct nWorker *next;
};
//线程池管理组件
typedef struct nManager { //typedef 是一个C/C++编程语言中的关键字,它用于定义新的数据类型别名
struct nTask *tasks;
struct nWorker *workers;
pthread_mutex_t mutex;
pthread_cond_t cond; //这是一个条件变量,用于在多线程中进行线程之间的同步和通信。条件变量通常用于等待某些条件的发生,以控制线程的执行流程。在这里,它用于通知工作线程是否有任务可执行。
} ThreadPool;
/* nThreadPoolCallback 函数是工作线程的入口函数,它在一个循环中等待任务,当任务队列中有任务时,
工作线程会执行任务函数,并循环处理。当没有任务时,工作线程会等待条件变量 cond 的通知。*/
// callback != task
static void *nThreadPoolCallback(void *arg) {
struct nWorker *worker = (struct nWorker*)arg; //通过将 arg 强制类型转换为 struct nWorker 的指针,从参数中获取工作线程的信息
while (1) {
pthread_mutex_lock(&worker->manager->mutex);
while (worker->manager->tasks == NULL) { //在互斥锁内部,工作线程检查任务队列是否为空。如果任务队列为空,工作线程将进入等待状态,等待任务被添加到队列中.
if (worker->terminate) break;
pthread_cond_wait(&worker->manager->cond, &worker->manager->mutex);
}
if (worker->terminate) { //如果工作线程的 terminate 标志为真,表示线程池已经要求工作线程终止(例如,当线程池被销毁时)。在这种情况下,工作线程会释放互斥锁并终止线程。
pthread_mutex_unlock(&worker->manager->mutex);
break;
}
struct nTask *task = worker->manager->tasks; //如果任务队列不为空,工作线程从任务队列中取出一个任务。
LIST_REMOVE(task, worker->manager->tasks); //工作线程使用 LIST_REMOVE 宏来将已经取出的任务从任务队列中移除。
pthread_mutex_unlock(&worker->manager->mutex); //在处理完任务后,工作线程释放互斥锁,允许其他线程访问任务队列。
task->task_func(task); //工作线程执行任务的实际处理函数,通过调用 task_func 来执行任务。
}
free(worker); //作线程释放它自己的内存,以结束线程的执行。
}
// API
/* nThreadPoolCreate 函数用于创建线程池,它接受一个 ThreadPool 结构体和指定的工作线程数量。
它会初始化线程池的各个字段,创建指定数量的工作线程,并将它们添加到线程池的工作线程队列中。*/
int nThreadPoolCreate(ThreadPool *pool, int numWorkers) {
if (pool == NULL) return -1;
if (numWorkers < 1) numWorkers = 1;
memset(pool, 0, sizeof(ThreadPool)); //使用 memset 函数将整个线程池结构的内存清零,以初始化线程池的各个成员。
pthread_cond_t blank_cond = PTHREAD_COND_INITIALIZER;
memcpy(&pool->cond, &blank_cond, sizeof(pthread_cond_t)); //条件变量cond初始化
//pthread_mutex_init(&pool->mutex, NULL);
pthread_mutex_t blank_mutex = PTHREAD_MUTEX_INITIALIZER; //互斥锁初始化
memcpy(&pool->mutex, &blank_mutex, sizeof(pthread_mutex_t));
int i = 0;
for (i = 0;i < numWorkers;i ++) {
struct nWorker *worker = (struct nWorker*)malloc(sizeof(struct nWorker));
if (worker == NULL) {
perror("malloc");
return -2;
}
memset(worker, 0, sizeof(struct nWorker));
worker->manager = pool; //
int ret = pthread_create(&worker->threadid, NULL, nThreadPoolCallback, worker);
if (ret) {
perror("pthread_create");
free(worker);
return -3;
}
LIST_INSERT(worker, pool->workers);
}
// success
return 0;
}
/* 这段代码的主要功能是初始化线程池的结构、条件变量和互斥锁,以及创建指定数量的工作线程,为线程池的正常运行做好准备。这是线程池的初始化阶段,确保线程池能够接受和执行后续提交的任务 */
// API
/* nThreadPoolDestory 函数用于销毁线程池。
它会终止线程池中的所有工作线程,释放资源,并清空任务队列*/
int nThreadPoolDestory(ThreadPool *pool, int nWorker) {
struct nWorker *worker = NULL;
for (worker = pool->workers;worker != NULL;worker = worker->next) { //循环终止工作线程:这个部分的目标是停止线程池中的所有工作线程
worker->terminate;
}
pthread_mutex_lock(&pool->mutex); //锁定线程池的互斥锁:使用 pthread_mutex_lock(&pool->mutex); 锁定线程池的互斥锁,这是为了确保在后续的操作中不会有其他线程同时访问线程池
pthread_cond_broadcast(&pool->cond); //广播条件变量:通过 pthread_cond_broadcast(&pool->cond);,向所有等待在条件变量 pool->cond 上的线程发送信号,
//以通知它们可以继续执行。这是为了唤醒所有可能在等待任务的工作线程。
pthread_mutex_unlock(&pool->mutex); //解锁互斥锁:使用 pthread_mutex_unlock(&pool->mutex); 解锁线程池的互斥锁,允许其他线程再次访问线程池。
pool->workers = NULL; //清空工作线程列表和任务列表
pool->tasks = NULL;
return 0;
}
// API
/* nThreadPoolPushTask 函数用于将任务添加到线程池的任务队列中,以等待工作线程执行 */
int nThreadPoolPushTask(ThreadPool *pool, struct nTask *task) {
pthread_mutex_lock(&pool->mutex);
LIST_INSERT(task, pool->tasks);
pthread_cond_signal(&pool->cond); //用于唤醒一个工作线程,告诉它们有任务可供执行
pthread_mutex_unlock(&pool->mutex);
}
#if 1
#define THREADPOOL_INIT_COUNT 20
#define TASK_INIT_SIZE 1000
void task_entry(struct nTask *task) { //任务执行函数,用于执行线程池中的任务
//struct nTask *task = (struct nTask*)task;
int idx = *(int *)task->user_data;
printf("idx: %d\n", idx);
free(task->user_data);
free(task);
}
int main(void) {
ThreadPool pool = {0}; //结构体实例化。在栈上分配内存空间,并初始化为0
nThreadPoolCreate(&pool, THREADPOOL_INIT_COUNT);
// pool --> memset();
int i = 0;
for (i = 0;i < TASK_INIT_SIZE;i ++) {
struct nTask *task = (struct nTask *)malloc(sizeof(struct nTask));
if (task == NULL) {
perror("malloc");
exit(1);
}
memset(task, 0, sizeof(struct nTask));
task->task_func = task_entry;
task->user_data = malloc(sizeof(int));
*(int*)task->user_data = i;
nThreadPoolPushTask(&pool, task);
}
getchar(); //getchar()函数用于在程序执行到末尾时等待用户按下键盘上的任意键,以防止程序立即退出并关闭终端窗口。
}
#endif
4.简单实例分析
1.C 创建线程示例
#include <stdio.h>
#include <pthread.h>
// 线程函数,打印一条消息
void *print_message(void *arg) {
printf("Hello, I'm a thread!\n");
}
int main() {
pthread_t thread; // 定义线程变量
// 创建线程,并调用print_message函数
pthread_create(&thread, NULL, print_message, NULL);
// 等待线程执行完毕
pthread_join(thread, NULL);
return 0;
}
2. C++创建线程示例
#include <iostream>
#include <thread>
// 线程函数,打印一条消息
void print_message() {
std::cout << "Hello, I'm a thread!" << std::endl;
}
int main() {
// 创建线程,并调用print_message函数
std::thread thread1(print_message);
// 等待线程执行完毕
thread1.join();
return 0;
}
常用函数:
this_thread::get_id():获取当前线程的唯一标识符。
this_thread::sleep_for():使当前线程睡眠一段指定的时间。
join():等待线程结束。主线程会阻塞,直到被调用 join 的线程执行完毕。
detach():将线程与当前线程分离,使得它可以独立执行,不再由当前线程管理。
四、多进程并发
1.多进程编程基础
-
创建进程:通过调用操作系统提供的系统调用(如
fork()
在 Unix/Linux 系统中),在当前进程的基础上创建一个新的进程。新进程称为子进程,原进程称为父进程。 -
进程间通信:用于在不同进程之间传递数据和共享资源。常见的 IPC 方法包括管道、共享内存、信号量等。每种 IPC 方法都有其特定的应用场景和适用性。
-
进程同步与互斥:多个进程之间的并发执行可能会导致竞争条件和资源冲突。为了避免这种情况,需要使用同步和互斥机制来控制进程的访问顺序和共享资源的访问权限,如互斥锁、信号量等。
2.进程间通信方式
进程间通信(IPC,Inter-Process Communication)是多进程编程中非常重要的一部分,它允许不同进程之间进行数据交换和共享资源。常见的进程间通信方式包括:
(1)管道(Pipe):
- 管道是一种单向通信机制,分为匿名管道和命名管道。
- 匿名管道通过调用系统调用
pipe()
来创建,其中包括一个读端和一个写端,数据在管道中单向流动。 - 命名管道(FIFO)是一种特殊类型的管道,通过文件系统提供,允许多个无亲缘关系的进程进行通信。
(2)消息队列(Message Queue):
- 消息队列允许进程通过消息进行通信,消息可以是任意大小的数据块。
- 消息队列具有不同的优先级,并且支持按优先级获取消息。
- 在Unix/Linux系统中,可以使用
msgget()
、msgsnd()
和msgrcv()
等函数进行消息队列的创建、发送和接收。
(3)共享内存(Shared Memory):
- 共享内存允许多个进程访问同一块物理内存,从而实现高效的数据共享。
- 共享内存适用于需要频繁读写大量数据的场景。
- 在Unix/Linux系统中,可以使用
shmget()
、shmat()
和shmdt()
等函数进行共享内存的创建、映射和解除映射。
(4)信号量(Semaphore):
- 信号量是一种计数器,用于控制多个进程对共享资源的访问。
- 通过对信号量的操作,可以实现进程之间的同步和互斥。
- 在Unix/Linux系统中,可以使用
semget()
、semop()
和semctl()
等函数进行信号量的创建、操作和控制。
(5)套接字(Socket):
- 套接字是一种在网络中进行通信的方式,不仅可以用于不同主机之间的通信,也可以用于同一主机上不同进程之间的通信。
- 套接字提供了面向连接的通信(如TCP套接字)和面向消息的通信(如UDP套接字)两种方式。
- 在Unix/Linux系统中,可以使用
socket()
、bind()
、connect()
和recv()
、send()
等函数进行套接字编程。
以上是常见的进程间通信方式,每种方式都有其适用的场景和特点。在实际的多进程编程中,通常会根据具体需求选择合适的通信方式来实现进程间的数据交换和共享资源。
3.示例分析
(1)c++ 创建多个进程实例
#include <iostream>
#include <unistd.h> // 用于 fork() 函数
#include <sys/wait.h> // 用于 wait() 函数
int main() {
const int NUM_PROCESSES = 5;
for (int i = 0; i < NUM_PROCESSES; ++i) {
pid_t pid = fork(); // 创建子进程
if (pid == 0) { // 如果是子进程
std::cout << "子进程 ID: " << getpid() << std::endl;
return 0; // 子进程结束
} else if (pid < 0) { // 如果 fork() 失败
std::cerr << "创建子进程失败" << std::endl;
return 1;
}
}
// 在父进程中等待所有子进程结束
for (int i = 0; i < NUM_PROCESSES; ++i) {
wait(NULL);
}
return 0;
}
这个程序创建了5个子进程,每个子进程打印自己的进程ID。父进程等待所有子进程结束后退出。在Linux系统中编译和运行这个程序时,你会看到5个不同的进程ID被打印出来。其中fork() 函数用于创建进程,getpid()用于获取进程ID号,wait()
函数是用于等待子进程结束的系统调用,在父进程中常用于等待子进程的终止状态。
详解fork()函数:调用 fork()
函数之后,操作系统会创建一个新的进程,称为子进程,它是父进程的副本。这意味着在子进程中,包括了父进程的所有代码、数据和执行状态,从 fork()
返回后,父子进程都会从该函数返回,继续执行接下来的代码。使用条件判断pid的值确定是子进程还是父进程。
(2)C++ 使用管道进行进程间通信示例
#include <iostream>
#include <unistd.h> // 包含管道相关的头文件
#include <sys/wait.h> // 包含 wait() 函数相关的头文件
int main() {
int pipefd[2]; // 管道文件描述符数组,pipefd[0] 用于读取,pipefd[1] 用于写入
pid_t pid;
char message[] = "Hello, child!";
// 创建管道
if (pipe(pipefd) == -1) {
std::cerr << "创建管道失败" << std::endl;
return 1;
}
pid = fork(); // 创建子进程
if (pid == -1) {
std::cerr << "创建子进程失败" << std::endl;
return 1;
} else if (pid == 0) { // 子进程
close(pipefd[1]); // 关闭子进程中的写入端
char buffer[50];
read(pipefd[0], buffer, sizeof(buffer)); // 读取父进程发送的消息
std::cout << "子进程收到消息: " << buffer << std::endl;
close(pipefd[0]); // 关闭读取端
return 0;
} else { // 父进程
close(pipefd[0]); // 关闭父进程中的读取端
// 向子进程发送消息
write(pipefd[1], message, sizeof(message));
close(pipefd[1]); // 关闭写入端
wait(NULL); // 等待子进程结束
}
return 0;
}
在这个示例中,我们使用 pipe()
创建了一个管道,然后使用 fork()
创建了一个子进程。子进程关闭了管道的写入端,然后从管道的读取端读取来自父进程的消息。父进程关闭了管道的读取端,然后向管道的写入端写入消息,并等待子进程结束。
管道是怎样读写数据的?会不会出现父进程还没写入数据,子进程就读取了?为什么要提前关闭管道的读写端?
管道是一个先进先出的数据结构,数据在管道中按照写入顺序存放,而读取操作会从管道中获取先前写入的数据。因此,在这个示例中,父进程写入的数据会在子进程读取之前被存储在管道中。
如果父进程没有写入数据,而子进程尝试读取数据,那么子进程的 read()
函数会被阻塞,直到有数据可读取或者管道被关闭为止。
管道的关闭是为了确保进程在不再需要读取或写入数据时关闭相应的端口,这样可以避免资源泄漏并确保程序的正常执行。
感谢大家的阅读!如果觉得还不错的话,希望可以点赞收藏。谢谢!\(0^◇^0)/