一、前言
前几天逛知乎,有幸看到某位知友提问linux 信号量是什么怎么用?然后他贴了一个书上使用信号量的例子出来(这个例子有个不是很明显的错误,看看大家能不能找出来):
为了让大家能看得更舒服和便于复制黏贴到IDE运行(我是在 Visual Studio中配置pthread运行的,linux中也是可以运行的),我将上述图片中的代码一个字一个字敲到下面:
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include<Windows.h>
#pragma comment(lib,"pthreadVC2.lib")
void* thread_function(void* arg);
sem_t bin_sem;
#define WORK_SIZE 1024
char work_area[WORK_SIZE];
int main()
{
int res;
pthread_t a_thread;
void* thread_result;
res = sem_init(&bin_sem, 0, 0);
if (res != 0)
{
perror("初始化信号量失败");
exit(EXIT_FAILURE);
}
res = pthread_create(&a_thread, NULL, thread_function, NULL);
if (res != 0)
{
perror("线程创建失败");
exit(EXIT_FAILURE);
}
printf("请输入要传送的信息,输入'end'退出\n");
while (strncmp("end", work_area, 3) != 0)
{
fgets(work_area, WORK_SIZE, stdin);
sem_post(&bin_sem);
}
printf("\n等待线程结束...\n");
res = pthread_join(a_thread, &thread_result);
if (res != 0)
{
perror("线程技术失败");
exit(EXIT_FAILURE);
}
printf("线程结束\n");
sem_destroy(&bin_sem);
exit(EXIT_SUCCESS);
}
void* thread_function(void* arg)
{
sem_wait(&bin_sem);
while (strncmp("end", work_area, 3) != 0)
{
//Sleep(2000);
printf("收到%d个字符,内容:%s\n", strlen(work_area) - 1, work_area);
sem_wait(&bin_sem);
}
pthread_exit(NULL);
return NULL;
}
上述代码很简单,就是子线程阻塞等待,直到主线程接收到键盘输入为止。代码中用了一个信号量bin_sem来作同步,当缓冲区work_area没有数据时,阻止子线程打印数据。程序开始运行时,由于信号量为0,子线程会通过sem_wait函数等待,主线程通过fgets函数接收到键盘输入时,会将该输入存入work_area缓冲区,信号量bin_sem加1,这时候子线程就可以继续执行,打印缓冲区的数据了。运行结果如下:
二、问题
上述的例子看上去似乎没有问题,但真的没有错误吗?很遗憾,这个例子是有bug的。原因在于主线程接收键盘输入的过程是没有阻塞的,这样可能就会存在这么一种情况:在子线程打印缓冲区work_area的数据之前,主线程就调用下一个fgets把数据覆盖了。一般来讲键盘输入是没有这么快的,所以我们可以通过降低子线程的打印速度来重现该问题:
我们把函数thread_function改动如下,增加语句Sleep(2000)来让子线程睡眠2秒。
void* thread_function(void* arg)
{
sem_wait(&bin_sem);
while (strncmp("end", work_area, 3) != 0)
{
Sleep(2000);
printf("收到%d个字符,内容:%s\n", strlen(work_area) - 1, work_area);
sem_wait(&bin_sem);
}
pthread_exit(NULL);
return NULL;
}
重新编译运行,我们可以看到确实可能存在数据被覆盖的情况:
三、解决方法
解决方法很简单,上述代码中只使用了一个信号量。我们再加一个信号量,当缓冲区work_area有数据时,阻止键盘输入。
更改后的代码如下:
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include<Windows.h>
#pragma comment(lib,"pthreadVC2.lib")
void* thread_function(void* arg);
sem_t empty_sem; //同步信号量,当缓冲区中有数据时阻止键盘输入
sem_t full_sem; //同步信号量,当缓冲区中没有数据时阻止打印里面的数据
#define WORK_SIZE 1024
char work_area[WORK_SIZE];
int main()
{
int res1,res2;
pthread_t a_thread;
void* thread_result;
res1 = sem_init(&empty_sem, 0, 1);
res2 = sem_init(&full_sem, 0, 0);
if (res1 != 0 || res2 != 0)
{
perror("初始化信号量失败");
exit(EXIT_FAILURE);
}
int res = pthread_create(&a_thread, NULL, thread_function, NULL);
if (res != 0)
{
perror("线程创建失败");
exit(EXIT_FAILURE);
}
printf("请输入要传送的信息,输入'end'退出\n");
while (1)
{
sem_wait(&empty_sem); //给信号量减1操作
if (strncmp("end", work_area, 3) != 0)
{
fgets(work_area, WORK_SIZE, stdin);
sem_post(&full_sem);
}
else
{
sem_post(&full_sem);
break;
}
}
printf("\n等待线程结束...\n");
res = pthread_join(a_thread, &thread_result);
if (res != 0)
{
perror("线程技术失败");
exit(EXIT_FAILURE);
}
printf("线程结束\n");
sem_destroy(&empty_sem);
sem_destroy(&full_sem);
exit(EXIT_SUCCESS);
}
void* thread_function(void* arg)
{
while (1)
{
sem_wait(&full_sem);
if (strncmp("end", work_area, 3) != 0)
{
Sleep(2000);
printf("收到%d个字符,内容:%s\n", strlen(work_area) - 1, work_area);
sem_post(&empty_sem);
}
else
{
sem_post(&empty_sem);
break;
}
}
pthread_exit(NULL);
return NULL;
}
改动代码后,运行,我们可以看到缓冲区中有数据时,是没办法用键盘输入的
四、使用队列优化
“三”中的代码存在一个不足之处:缓冲区只有一个。当缓冲区已经有数据的时候,就没办法再接收键盘输入了。我们可以将上述例子中所用的缓冲区改成队列,将队列的最大大小改成5(通过信号量设置)。这样在队列满之前,都可以继续通过队列存贮输入的数据。注意为了避免主线程和子线程同时访问队列,我们得加锁保护。
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <queue>
#include <string>
#include <Windows.h>
#pragma comment(lib,"pthreadVC2.lib")
using namespace std;
void *thread_function(void *arg);
sem_t empty_sem; //同步信号量,当缓冲区中有数据时阻止键盘输入
sem_t full_sem; //同步信号量,当缓冲区中没有数据时阻止打印里面的数据
pthread_mutex_t mutex; //互斥信号量,一次只有一个线程访问队列
#define WORK_SIZE 1024
queue<string> queWorkArea;
int main()
{
int res1,res2;
pthread_t a_thread;
void* thread_result;
res1 = sem_init(&empty_sem, 0, 5);
res2 = sem_init(&full_sem, 0, 0);
if (res1 != 0 || res2 != 0)
{
perror("初始化信号量失败");
exit(EXIT_FAILURE);
}
int mutexinit = pthread_mutex_init(&mutex, NULL);
if (mutexinit != 0)
{
perror("初始化互斥锁失败");
exit(EXIT_FAILURE);
}
int res = pthread_create(&a_thread, NULL, thread_function, NULL);
if (res != 0)
{
perror("线程创建失败");
exit(EXIT_FAILURE);
}
printf("请输入要传送的信息,输入'end'退出\n");
char work_area[WORK_SIZE] = { 0 };
while (1)
{
sem_wait(&empty_sem); //给信号量减1操作
memset(work_area, 0, sizeof(work_area));
fgets(work_area, WORK_SIZE, stdin);
pthread_mutex_lock(&mutex);
queWorkArea.push(work_area);
pthread_mutex_unlock(&mutex); //释放互斥量对象
sem_post(&full_sem);
if (strncmp("end", work_area, 3) == 0)
{
break;
}
}
printf("\n等待线程结束...\n");
res = pthread_join(a_thread, &thread_result);
if (res != 0)
{
perror("线程结束失败");
exit(EXIT_FAILURE);
}
printf("线程结束\n");
sem_destroy(&empty_sem);
sem_destroy(&full_sem);
exit(EXIT_SUCCESS);
}
void* thread_function(void* arg)
{
char work_area[WORK_SIZE] = { 0 };
while (1)
{
sem_wait(&full_sem);
Sleep(2000);
pthread_mutex_lock(&mutex);
memset(work_area, 0, sizeof(work_area));
if (strncmp("end", queWorkArea.front().c_str(), 3) != 0)
{
strcpy(work_area, queWorkArea.front().c_str());
queWorkArea.pop();
printf("收到%d个字符,内容:%s\n", strlen(work_area) - 1, work_area);
pthread_mutex_unlock(&mutex); //释放互斥量对象
sem_post(&empty_sem);
}
else
{
pthread_mutex_unlock(&mutex); //释放互斥量对象
sem_post(&empty_sem);
break;
}
}
pthread_exit(NULL);
return NULL;
}
运行结果如下:
五、多个生产者和消费者
相信大家已经看出来了,上述的例子已经是生产者消费者模型的雏形了。实际项目中可能会存在多个生产者和消费者,例子可以参考:《进程间通信的方式(四):信号量》
六、总结
说了那么多大家是不是还是一脸懵逼?其实很简单:生产者消费者的模型中必须有两个信号量或者两个条件变量来作同步,其中一个负责在缓冲区满的时候阻止生产者继续把数据放入里面,另一个负责在缓冲区空时阻止消费者消费。所以大家以后看到这种模型,如果只用到一个信号量,那大概率就是有问题了。可能有些朋友会说:“用个啥信号量/条件变量!我不断轮询判断缓冲区是不是满和空不就可以了吗?”。这样从功能上讲确实可以,但是轮询有个问题:如果sleep的时间太短,会很耗费电脑CPU,影响程序性能;如果sleep的时间过长,则会导致响应速度过慢,影响用户体验。所以生产者消费者模型必须用信号量或者条件变量作同步。关于轮询和信号量/条件变量性能对比可以参考:《C++ 队列轮询和条件变量的性能比较》