在多线程应用中,利用互斥锁和条件变量可以实现多线程同步操作,以及对共享资源的保护访问。但是通过观察可以发现,前面文章中设计的通过互斥锁和条件变量所保护的共享数据块,必须在其数据结构中设定单独的计数变量来保存当前共享数据块的操作状态(可用数目、写入位置等)。那么,能否有一种线程间同步机制,在实现多线程同步访问共享数据的同时,自身又能够承载共享资源的累计信息呢?答案是存在的,即信号量。
信号量是一种轻型的用于解决线程间同步问题的内核对象,线程可以获取或释放它,从而达到同步或互斥的目的。 信号量本质上是一个计数器, 与其它进程间通信方式不大相同, 它主要用于控制多个进程间或一个进程内的多个线程间对共享资源的访问, 相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源,同时,进程也可以修改该标志, 除了用于共享资源的访问控制外,还可用于进程同步。它常作为一种锁机制, 防止某进程在访问资源时其它进程也访问该资源, 因此, 主要作为进程间以及同一个进程内不同线程之间的同步手段。
从信号量的特点进行分类,主要分为计数信号量和二值信号量,前者的数值是一个大于0的数值,可以用来表示某种资源的数量,并且可以通知等阻塞等待该资源的线程来获取资源。后者数值被限定为0和1,其功能变成了类似互斥锁的机制,此时的信号量用来对共享资源进行保护,而不再具备计数功能。
信号量的操作过程中,等待信获取号量和发布信号量必须成对使用,不能单独出现,而且必须先等待获取,当得到一个信号之后再发布。
int sem_init(sem_t *sem, int pshared, unsigned int value);
此函数初始化一个无名信号量 sem,根据给定的或默认的参数对信号量相关数据结构进行初始化。
当使用 sem_destroy()销毁了一个未命名信号量之后就能够使用 sem_init()来重新初始化这个信号量了。一个未命名信号量(本篇只介绍未命名信号量,有名信号量暂不涉及)应该在其底层的内存被释放之前被销毁 信号量在使用函数原型如下:
int sem_destroy(sem_t *sem);
函数参数和返回值含义如下:
返回值: 成功返回 0;失败将返回一个非 0 的错误码。
获取一个信号量可以有以下几种不同的方式,其函数原型如下所示:
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
sem_wait函数会使当前前线程阻塞等待获取信号量,直到另一个线程发布信号量才唤醒。
sem_trywait函数将会以非阻塞的方式获取信号量,不管是否获取到,都会及时返回。
sem_timedwait函数可以设置阻塞等待的时间,超时之后,不管是否获取到信号量,都返回。
int sem_post(sem_t *sem);
如果在 sem_post()调用之前信号量的值为 0,并且其他某个进程(或线程)正在因等待递减这个信号量而阻塞,那么该进程会被唤醒,它的 sem_wait()调用会继续往前执行来递减这个信号量。 其函数原型如下所示:
生产者-消费者模型是操作系统多线程资源同步访问的一个标准情景,在该模型下,可以用来模拟测试各种线程同步机制对多线程对共享资源的竞争访问流程,是测试和研究多线程同步较好的实例。本设计采用mutex来对共享数据资源进行保护,实现生产者线程和消费者线程的同步。
在本设计中,拟定共享资源将会成环形操作,即数据在内存块内放满之后会回到起始位置循环覆盖。 首先设计共享资源对象,具体数据结构如下:
struct ring_shared_obj
{
int* buf;
sem_t* sem_mutex;
sem_t* sem_empty;
sem_t* sem_stored;
int len;
};
typedef struct ring_shared_obj ring_shared_t;
对比前一篇文章的共享数据设计,可以观察到,该共享数据块内部,没有在单独添加用于标记共享资源数目的变量,取而代之的是,利用sem_empty和sem_stored来分别记录和传递共享数据块的空余数据数目和已占用数据条目。sem_mutex信号量将会被初始化成二值信号量,该信号量用来实现互斥锁的功能,来对共享数据块进行保护。len数据用来记录数据块的最大容量。
享数据块句柄采用动态内存分配的方式来创建共享数据块对象句柄,这种方式较为灵活,可以根据需求创建符合需求的数据块大小。该函数入口参数是需要创建的数据块的大小(默认int型数据),返回创建好的共享数据块的句柄指针。
ring_shared_t* ring_shared_create(int size)
{
ring_shared_t* pargv = NULL;
if(size==0)
{
printf("[error] init ring shared buf size invaild!\r\n");
return NULL;
}
pargv = (ring_shared_t*)malloc(sizeof(struct ring_shared_obj));
if(pargv == NULL)
{
printf("[error] malloc ring shared error!\r\n");
return NULL;
}
pargv->sem_mutex = (sem_t*)malloc(sizeof(sem_t));
pargv->sem_empty = (sem_t*)malloc(sizeof(sem_t));
pargv->sem_stored = (sem_t*)malloc(sizeof(sem_t));
if(sem_init(pargv->sem_mutex, 0, 1)!=0 ||
sem_init(pargv->sem_empty, 0, MAXSIZE)!=0 ||
sem_init(pargv->sem_stored, 0, 0)!=0)
{
perror("[error] open create sems error!\r\n");
}
pargv->buf = (int*)malloc(sizeof(int)*size);
if(pargv->buf == NULL)
{
printf("[error] malloc ring shared buf error!\r\n");
return NULL;
}
memset(pargv->buf,0,size);
pargv->len = size;
return pargv;
}
动态分配的数据块对象句柄,在这个应用程序退出时,应该把从系统内配的内存统一释放归还给系统,避免出现不可预测内存问题。所以,在程序结束时,调用该销毁函数,实现对共享资源的释放。
int ring_shared_destroy(ring_shared_t* pargv)
{
if(pargv == NULL)
{
printf("[error] destroy shared error!\r\n");
return -1;
}
sem_destroy(pargv->sem_mutex);
sem_destroy(pargv->sem_empty);
sem_destroy(pargv->sem_stored);
free(pargv);
pargv = NULL;
return 0;
}
在该情境下,生产者线程首先阻塞等待共享内存块的空位信号量sem_empty,sem_empty在初始化阶段内存块的空位被初始化为内存块的最大容量,因此该线程伊始便能够获取到表示空位的信号量,之后阻塞获取二值信号量sem_mutex,sem_mutex信号量等效为互斥锁,用来保护共享资源。操作完共享数据之后,一次解锁二值信号量sem_mutex,已经释放一个满位信号量sem_stored,以此来唤醒消费者线程由内容可以读取。
每次数据写入后,让线程随机休眠一段时间,来模拟生产者线程对共享资源访问的随机性。当数据写入位置到数据块的次数达到了程序设定的阈值时,生产者线程则退出。在此过程中,如果共享内存块被写满,则会从头开始循环覆盖写入。实现环形存储区的效果。
static void* produce_entry(void* argc)
{
for(int i=0; i<DATAITEM; i++)
{
sem_wait(ring_shared_handle->sem_empty);
sem_wait(ring_shared_handle->sem_mutex);
ring_shared_handle->buf[i%MAXSIZE] = i;
sem_post(ring_shared_handle->sem_mutex);
sem_post(ring_shared_handle->sem_stored); /* 释放一个满位 */
usleep(rand()%3);
}
pthread_exit(0);
return NULL;
}
消费者线程会按照创建的资源总长度来遍历该共享资源内的数据,在没有访问到资源数末尾的时候,利用每次把数据取出进行数据比对校验来模拟消费者线程对共享资源的访问和操作,如果数据比对失败,则意味着生产者填入的某个数据出现了错误,并且此时进行报错输出,如果数据正确,则分别输出当前共享内存块的位置和该位置上对应的实际数据。
消费者线程的执行流程和生产者相反,消费者线程会先阻塞等待满位信号量sem_stored,如果生产者成功生产一个数据,则会唤醒消费者线程来取数据,在消费者线程操作共享内存块上的数据时,也需要先获到二值信号量sem_mutex,实现安全的访问共享资源的目的。在操作完共享资源后,消费者线程要主动释放空信号量sem_empty,以此来唤醒阻塞在该信号量上的生产者线程,使其继续生产数据到共享内存块中。
消费者线程用随机睡眠时间,来模拟消费者对共享资源的访问随机效果。
static void* consume_entry(void* argc)
{
for(int i=0; i<DATAITEM; i++)
{
sem_wait(ring_shared_handle->sem_stored);
sem_wait(ring_shared_handle->sem_mutex);
if(ring_shared_handle->buf[i%MAXSIZE] != i)
{
printf("[error] data[%d]: %d \r\n",i%MAXSIZE,ring_shared_handle->buf[i]);
}
else
{
printf("[%d][%d] ",i%MAXSIZE, ring_shared_handle->buf[i%MAXSIZE]);
}
sem_post(ring_shared_handle->sem_mutex);
sem_post(ring_shared_handle->sem_empty); /* 释放一个空位 */
usleep(rand()%3);
}
printf("\r\n");
pthread_exit(0);
return NULL;
}
在主程序之前,首先定义了如下的宏来分别表示要生产和消费的数据总数目,以及共享内存块最大尺寸(单位是int)。
#define DATAITEM 20
#define MAXSIZE 5
本设计中,设定共享数据块的大小为20个int数据的容量,生产者线程数量1,消费者线程数量默认为1。创建完各个线程之后,即把各个线程一次设置为可连接状态,此时主线程会阻塞,直到子线程执行完毕。根据打印出来的数据信息,可以观察到,20个资源数据是循环写入到容量为5个int的共享内存块中的。最后,在主线程退出之前,调用销毁接口来释放共享数据块的资源。
int sem_demo(void)
{
pthread_t consume_thread;
pthread_t produce_thread;
printf("[sem_demo]\r\n");
ring_shared_handle = ring_shared_create(MAXSIZE);
if(ring_shared_handle == NULL)
{
printf("[error] create shared error!\r\n");
return -1;
}
ring_shared_info(ring_shared_handle);
pthread_create(&produce_thread, NULL, produce_entry, NULL);
pthread_create(&consume_thread, NULL, consume_entry, NULL);
pthread_join(produce_thread, NULL);
pthread_join(consume_thread, NULL);
ring_shared_destroy(ring_shared_handle);
return 0;
}
本篇从互斥锁和条件变量实现多线程同步机制的另一个“缺点”(非贬义)来引入了信号量这一新的多线程同步机制,并且阐述了该机制本质上和其他方式的差别。然后继续针对经典的“生产者-消费者”模型入手,重新结合信号量来设计了新的共享内存块,并且设计了生产者和消费者线程利用信号量实现同步的流程。通过该例程的设计,可以更好的理解信号量操作的具体方式。
作为系列文章,照旧在本篇的结尾附上自己喜欢的一段诗。生活是否像一段程序一样存在栈帧?如果存在,那在这段时光里的祈祷,它的生命周期又能延续多久?
今天的太阳
像瘫痪的卡车
沉重地运走,整个下午
白醋,春梦,野袖子
把回忆揣进手掌的血管里
手电的光透过掌背
仿佛看见跌入云端的海豚
——毕赣《路边野餐》(电影)配诗之一
高产!赞👍
@lchnu 谢谢您的鼓励!甚是惭愧,本人能力不行,只能分享点基础知识了,让大佬见笑了。
点赞
谢谢鼓励!@AngerCoke