一、为什么要实现线程同步
线程同步的主要意义是为了解决多线程间的共享资源访问问题。当多个线程同时访问同一共享资源时,就会出现数据竞争问题。如果没有有效的线程同步机制,这可能会导致不可预期的结果,例如数据损坏、程序崩溃等。
使用线程同步机制可以保证多个线程访问共享资源时的正确性和可靠性。线程同步机制有许多种,例如互斥锁、条件变量、信号量、读写锁等等。这些机制可以控制线程的并发访问,并防止多个线程同时读写共享资源,以此保证数据的安全性、一致性和有效性。
线程同步还可以提高程序的执行效率,对于频繁访问共享资源的应用,如果没有线程同步机制,可能会出现一些性能问题,例如资源等待、线程竞争等等。通过使用线程同步机制,可以有效地避免这些问题,提高程序的执行效率和并发性能。
二、线程同步问题
一、生产者和消费者
生产者消费者问题是一个经典的线程间协作问题。该问题涉及到两种角色:生产者和消费者。生产者负责生产产品,并将其放入共享缓冲区中,而消费者则负责从共享缓冲区中取出产品并进行消费。生产者和消费者之间通过共享缓冲区来进行协作。
生产者和消费者在一个多线程环境中运行时,会遇到一些问题,例如:
1. 数据竞争:如果多个生产者同时向缓冲区中添加产品,或者多个消费者同时从缓冲区中取出产品,则可能会发生数据竞争。这可能会导致数据丢失、数据错误等问题。
2. 队列满或空:如果缓冲区已满,生产者就无法再生产新的产品,而如果缓冲区已空,消费者就无法再消费产品。这也可能会导致线程死锁或者线程饥饿。
为了解决这些问题,我们可以采用不同的线程同步技术来进行同步和协作,例如:
1. 互斥锁和条件变量:可以使用互斥锁来保护共享缓冲区,以避免不同线程同时访问缓冲区。同时,可以使用条件变量来控制线程的等待和唤醒,以确保生产者在缓冲区不满时可以继续生产产品,而消费者在缓冲区不为空时可以继续消费产品。
2. 信号量:也可以使用信号量来进行线程同步和协作。通过设置两个信号量来表示缓冲区中的产品数量以及缓冲区的剩余空间,可以有效地避免队列满或空的问题。
总之,生产者消费者问题是一个经典的线程同步问题。通过合适的线程同步机制,可以有效地避免数据竞争、队列满或空等问题,确保多线程环境下的正确性和可靠性。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#define BUFFER_SIZE 10 // 缓冲区大小
#define PRODUCER_NUM 2 // 生产者个数
#define CONSUMER_NUM 2 // 消费者个数
#define PRODUCT_NUM 20 // 生产的产品数量
int buffer[BUFFER_SIZE]; // 缓冲区
int in = 0, out = 0; // 缓冲区头尾指针
sem_t mutex, empty, full; // 信号量
// 生产者线程
void *producer(void *param) {
int id = *(int *)param;
int i, product;
for (i = 0; i < PRODUCT_NUM; i++) {
product = rand() % 1000; // 生成随机产品
sem_wait(&empty); // 等待缓冲区有空闲位置
sem_wait(&mutex); // 互斥访问缓冲区
buffer[in] = product; // 生产产品
printf("Producer %d produced product: %d\n", id, product);
in = (in + 1) % BUFFER_SIZE; // 更新缓冲区头指针
sem_post(&mutex); // 释放互斥锁
sem_post(&full); // 增加产品数量
}
pthread_exit(NULL);
}
// 消费者线程
void *consumer(void *param) {
int id = *(int *)param;
int i, product;
for (i = 0; i < PRODUCT_NUM; i++) {
sem_wait(&full); // 等待缓冲区有产品可消费
sem_wait(&mutex); // 互斥访问缓冲区
product = buffer[out]; // 消费产品
printf("Consumer %d consumed product: %d\n", id, product);
out = (out + 1) % BUFFER_SIZE; // 更新缓冲区尾指针
sem_post(&mutex); // 释放互斥锁
sem_post(&empty); // 减少产品数量
}
pthread_exit(NULL);
}
int main() {
int i;
int producer_ids[PRODUCER_NUM];
int consumer_ids[CONSUMER_NUM];
pthread_t producer_threads[PRODUCER_NUM];
pthread_t consumer_threads[CONSUMER_NUM];
// 初始化信号量
sem_init(&mutex, 0, 1); // 互斥锁
sem_init(&empty, 0, BUFFER_SIZE); // 缓冲区空闲位置数
sem_init(&full, 0, 0); // 缓冲区中的产品数量
// 创建生产者线程
for (i = 0; i < PRODUCER_NUM; i++) {
producer_ids[i] = i;
pthread_create(&producer_threads[i], NULL, producer, &producer_ids[i]);
}
// 创建消费者线程
for (i = 0; i < CONSUMER_NUM; i++) {
consumer_ids[i] = i;
pthread_create(&consumer_threads[i], NULL, consumer, &consumer_ids[i]);
}
// 等待线程结束
for (i = 0; i < PRODUCER_NUM; i++) {
pthread_join(producer_threads[i], NULL);
}
for (i = 0; i < CONSUMER_NUM; i++) {
pthread_join(consumer_threads[i], NULL);
}
// 销毁信号量
sem_destroy(&mutex);
sem_destroy(&empty);
sem_destroy(&full);
return 0;
}
在该实现中,生产者和消费者通过信号量 empty 和 full 来实现线程同步。当缓冲区有空余位置时,生产者线程会执行生产操作,并通知消费者有产品可以消费。而当缓冲区有产品时,消费者线程会执行消费操作,并通知生产者有空余位置可以生产。
在整个过程中,线程的执行顺序和状态都是受到严格控制的,从而避免了数据竞争、队列满或空等问题。
二、哲学家进餐问题
哲学家进餐问题是一个经典的多线程同步问题,描述如下:
有五个哲学家,他们围坐在一张圆桌前,每个哲学家左右分别放有一只碗和一根筷子。哲学家只有同时拿到左右两根筷子,才能吃到桌子上放的面条,并且每个哲学家只能拿到自己左手边和右手边的筷子。假设哲学家们是交替思考的,这样就会出现死锁的情况:
当哲学家 1 和哲学家 2 抢到各自左手边的筷子后,同时需要右手边的筷子,但此时右手边的筷子已被哲学家 2 和哲学家 3 已经拿起,等待哲学家 2 和哲学家 3 放下右边筷子后,哲学家 1 和哲学家 2 才能拿到右手边的筷子,才能进餐。但是此时哲学家 3 和哲学家 4 又在等待左手边的筷子,导致死锁。
为了避免死锁,需要一种安全的算法来解决哲学家进餐问题。
一种简单而常见的解决算法是 Chandy/Misra 解法,它的核心思想是破坏死锁的必要条件——环路等待(circular wait)。该算法的步骤如下:
1. 给每个哲学家分配一个编号,分别从 0 到 4。
2. 给每个筷子单独分配一个编号,分别从 0 到 4。
3. 当哲学家需要用到两个筷子时,必须按照编号顺序来拿筷子,先拿编号小的筷子再拿编号大的筷子。
4. 当一个哲学家成功拿到两个筷子后,就可以吃面条了。此时,让哲学家先使用右边的筷子,再使用左边的筷子,吃完面条之后,把这两个筷子放回原处。
5. 当哲学家等待筷子的时候,如果发现等待的时间过长,就让他放下已经拿到的筷子,等待其他哲学家先进餐。
通过以上机制,可以保证所有哲学家都会拿到他们需要的筷子,而不会出现环路等待和死锁的情况。
除此之外,还有其他的解决算法,例如资源分配图解法、银行家算法等,并且每种算法都有其特点和应用场景。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define N 5 // 哲学家数量
sem_t chopstick[N]; // 筷子
sem_t mutex; // 互斥锁
void *philosopher(void *arg) {
int id = *(int*)arg;
int left = id;
int right = (id + 1) % N;
while (1) {
printf("Philosopher %d is thinking\n", id);
sleep(rand() % 5);
printf("Philosopher %d is hungry\n", id);
sem_wait(&mutex);
sem_wait(&chopstick[left]);
sem_wait(&chopstick[right]);
sem_post(&mutex);
printf("Philosopher %d is eating\n", id);
sleep(rand() % 5);
printf("Philosopher %d finished eating\n", id);
sem_post(&chopstick[left]);
sem_post(&chopstick[right]);
}
}
int main() {
pthread_t philosophers[N];
int ids[N];
sem_init(&mutex, 0, 1);
for (int i = 0; i < N; i++) {
sem_init(&chopstick[i], 0, 1);
ids[i] = i;
}
for (int i = 0; i < N; i++) {
pthread_create(&philosophers[i], NULL, philosopher, &ids[i]);
}
for (int i = 0; i < N; i++) {
pthread_join(philosophers[i], NULL);
}
for (int i = 0; i < N; i++) {
sem_destroy(&chopstick[i]);
}
sem_destroy(&mutex);
return 0;
}
这个实现使用了 POSIX 线程库来创建并发进程。程序创建了五个线程,每个线程代表一个哲学家,通过互斥锁来避免竞争条件,同时使用信号量来控制筷子。每个哲学家的线程进入一个死循环,随机休息一段时间再去抢筷子,如果成功获取到两只筷子,就进行进餐并释放筷子,否则会一直等待,直到成功获取到两只筷子
三、读者写者问题
读者写者问题是指在访问一个共享资源时,读和写两类操作之间的竞争关系所导致的一类并发问题。在读者写者问题中,读操作和写操作之间存在互斥关系,即同一时间只能有一个写操作或多个读操作。如果没有正确地管理读写锁,可能会导致写操作的数据竞争或读操作的饥饿问题。
读者写者问题通常包含以下两个因素:
读操作不会改变共享资源的内容,因此多个读操作可以同时访问共享资源,不会互相干扰或产生冲突。
写操作会改变共享资源的内容,因此同一时间只允许一个写操作访问,且需要互斥地进行。
经典的读者写者问题包括:
读者优先:多个读操作可以同时访问共享资源,但是写操作必须互斥进行,如果有写操作等待,则不再允许进行读操作。
写者优先:有写操作等待时,将优先满足写操作,否则可以同时满足多个读操作。
公平策略:读写操作各自维护一个等待队列,针对读操作和写操作分别实现公平的进程调度,保证所有的读者和写者都能得到公平的机会。
为了解决读者写者问题,通常会使用一些同步机制,如互斥锁、条件变量等。使用这些机制可以确保读操作和写操作之间只有一个在进行,从而避免冲突和数据竞争。
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#define N_READERS 5 // 读者数量
#define N_WRITERS 2 // 写者数量
int shared_data = 0; // 共享资源
int n_readers = 0; // 当前正在读取的读者数量
int waiting_writers = 0; // 等待写操作的数量
sem_t mutex; // 互斥锁
sem_t rw_mutex; // 读写锁
void *reader(void *arg) {
int id = *(int*)arg;
while (1) {
sem_wait(&mutex);
n_readers++;
if (n_readers == 1) {
sem_wait(&rw_mutex);
}
sem_post(&mutex);
printf("Reader %d is reading data[%d]\n", id, shared_data);
sleep(1);
sem_wait(&mutex);
n_readers--;
if (n_readers == 0) {
sem_post(&rw_mutex);
}
sem_post(&mutex);
}
}
void *writer(void *arg) {
int id = *(int*)arg;
while (1) {
sem_wait(&mutex);
if (n_readers == 0) {
sem_wait(&rw_mutex);
} else {
waiting_writers++;
sem_post(&mutex);
sem_wait(&rw_mutex);
waiting_writers--;
}
printf("Writer %d is writing data[%d]\n", id, ++shared_data);
sleep(1);
sem_post(&rw_mutex);
sem_post(&mutex);
}
}
int main() {
pthread_t readers[N_READERS];
pthread_t writers[N_WRITERS];
int reader_ids[N_READERS];
int writer_ids[N_WRITERS];
sem_init(&mutex, 0, 1);
sem_init(&rw_mutex, 0, 1);
for (int i = 0; i < N_READERS; i++) {
reader_ids[i] = i;
pthread_create(&readers[i], NULL, reader, &reader_ids[i]);
}
for (int i = 0; i < N_WRITERS; i++) {
writer_ids[i] = i;
pthread_create(&writers[i], NULL, writer, &writer_ids[i]);
}
for (int i = 0; i < N_READERS; i++) {
pthread_join(readers[i], NULL);
}
for (int i = 0; i < N_WRITERS; i++) {
pthread_join(writers[i], NULL);
}
sem_destroy(&mutex);
sem_destroy(&rw_mutex);
return 0;
}
在这个实现中,我们使用两个信号量来控制读写操作,其中 mutex(互斥锁)用来控制读者数量的修改操作,rw_mutex(读写锁)用来控制读操作和写操作之间的互斥关系。在读操作中,每个读者首先需要获取互斥锁 mutex,然后将正在读取的读者数量加 1,如果该读者是第一个读者则需要获取读写锁 rw_mutex。读操作完成后先释放互斥锁 mutex,然后将正在读取的读者数量减 1,如果没有任何其他读者正在读取,就需要释放读写锁 rw_mutex。写操作和读操作的实现差异较大,主要体现在写者需要优先获得读写锁 rw_mutex、读者需要优先获得互斥锁 mutex、还需要根据当前情况判断是否要等待的细节。