1 前言
在前面文章描述的互斥锁、读写锁、自旋锁用于互斥以保护共享资源,屏障用于同步多个线程,条件变量用于线程同步。屏障不能在线程间传递信息,条件变量存在信号丢失的风险(当然可以增加措施来避免)。本文描述另一个线程同步机制——信号量。信号量相比前面的同步(互斥)机制,更具灵活性和可靠性,不存在信号丢失的可能性,常用于“生产者—消费者”应用模型中。
2 信号量
POSIX信号量(Seamphore)是进程和线程间同步的一种机制。信号量本质是一个非负的整型变量,用于记录资源的可用数目,信号量的操作就是对该整型变量的原子操作,以实现线程的互斥或者同步。增加一个可用资源执行加一,也称为V操作;获取一个资源资源后执行减一,也称为P操作。当资源计数为0时,表示资源不可用,获取资源的线程被阻塞挂起,直至信号量可用再被唤醒执行。信号量是通过一个变量来计算信号值,因此不存在像条件变量那样信号丢失的可能,即使等待信号量线程未就绪。
信号量根据信号值(资源可用数目)的不同可以分为二值信号量和计数信号量。
- 二值信号量,信号量值只有0和1,初始值为1,1表示资源可用,0表示资源不可用;二值信号量与互斥锁类似
- 计数信号量, 信号量的值在0到一个大于1的限制值之间,信号值表示可用的资源的数目
信号量根据作用对象不同,可以分为有名信号量和无名信号量。
- 有名信号量,信号值保存在文件中,用于进程间同步
- 无名信号量,又称为基于内存信号量,信号值保存在内存中,用于线程间同步
2.1 信号量特点
- 用于线程同步
- 可引起线程睡眠,没有可用信号量,线程进入睡眠状态
- 持续性
- 不存在信号丢失
2.2 信号量适用场景
信号量使用的场景,典型的是“生产者——消费者”模型。生产者线程和消费者线程对同一块内存进行访问,生产者线程往共享内存写数据,消费者线程从共享内存读取数据,两个线程通过信号量同步。 典型场景有:
-
串行(USB、UART)数据数据接收与处理过程
-
数据异步输出(终端、文件)
-
文件读写
2.3 信号量使用原则
- None
3 信号量使用
信号量使用的基本步骤为:
【1】创建信号量实例
【2】初始化信号量
【3】信号量P操作
【4】信号量V操作
【5】销毁信号量
3.1 创建信号量
posix信号量以sem_t
数据结构表示。无名信号量和有名信号量创建稍有不同。
3.1.1 无名信号量创建
无名信号量实例可以用静态和动态创建。
sem_t sem;
3.1.2 有名信号量创建
如果是有名信号量,只需定义一个信号量指针只可以,信号量实例由通过名信号量函数sem_open
创建。
sem_t *sem_open(const char *name,int oflag, mode_t mode, unsigned int value);
name
,信号量名称oflag
,标识,根据传入标识来创建或者打开一个已创建的信号量mode
,访问权限value
,信号量初始值
标识 | 含义 |
---|---|
O_CREAT | 创建一个信号量 |
O_CREAT|O_EXCL | 信号量不存在则创建,已存在返回NULL |
0 | 信号量不存在返回NULL |
创建有名信号量伪代码:
sem_t *psem;
psem = sem_open(SEM_NAME, O_CREAT, 0555, 0);
3.2 初始化信号量
信号量初始化分为无名信号量和有名信号量,两者的初始化函数不同。
3.2.1 无名信号量初始化
int sem_init(sem_t *sem, int pshared, unsigned int value);
-
sem
,信号量实例地址,不能为NULL -
pshared
,信号量作用域,分为进程内作用域PTHREAD_PROCESS_PRIVATE
和跨进程作用域PTHREAD_PROCESS_SHARED
-
value
,信号量初始值 -
返回,成功返回0,失败返回-1,错误码存于
error
中,常见错误有:[1] 参数无效(EINVAL)
[2]
value
超出最大值SEM_VALUE_MAX
[3] 设置跨进程作用域,但当前系统未支持
[4] ENOSYS
3.2.2 有名信号量初始化
有名信号量初始化一般调用sem_open
函数创建即可,无需额外初始化,sem_open
函数使用参考3.1.2节。
3.3 获取信号量
int sem_getvalue(sem_t *sem, int *sval);
sem
,信号量实例地址,不能为NULLsvval
,保存返回信号值地址,不能为NULL- 返回,成功返回0,失败返回-1,错误码存于
error
中
通过sem_getvalue
函数获取当前信号量值,可用来判断可以用信号量。
3.4 等待信号量(P操作)
信号量等待方式分为阻塞和非阻塞方式,阻塞方式又分为无限阻塞方式和指定超时时间阻塞方式。等待信号量即是P操作,获取到一个信号量时,信号值减一。信号量的P操作是原子性的。
3.4.1 阻塞方式
int sem_wait(sem_t *sem);
sem
,信号量实例地址,不能为NULL- 返回,成功返回0,失败返回-1,错误码存于
error
中
该函数是阻塞方式等待信号量,如果信号值大于0,信号值减一,函数返回;如果信号值等于0,表示资源不可用,调用线程被阻塞挂起,直至信号量可用再被唤醒执行。等待信号量的线程按照先进先出原则,保证公平性。
3.4.2 指定超时时间阻塞方式
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
sem
,信号量实例地址,不能为NULLabstime
,超时时间,单位为时钟节拍- 返回,成功返回0,失败返回-1,错误码存于
error
中
该函数不会无限等待,超出指定时钟节拍后,仍未等待到可用信号量函数会返回ETIMEDOUT
,线程被唤醒执行。
3.4.3 非阻塞方式
int sem_trywait(sem_t *sem);
sem
,信号量实例地址,不能为NULL- 返回,成功返回0,失败返回-1,错误码存于
error
中
该函数即使信号量不可用也会立即返回,不会阻塞。
3.5 信号量产生(V操作)
int sem_post(sem_t *sem);
sem
,信号量实例地址,不能为NULL- 返回,成功返回0,失败返回-1,错误码存于
error
中
如果有线程正在等待信号量,则等待线程会被唤醒执行;如果没有任何线程等待信号量,则信号值执行加一,即是V操作。信号量的P操作和V操作都是原子性的,不会产生竞态。
3.6 信号量销毁
信号量销毁分为无名信号量和有名信号量,两者销毁函数不同。
3.6.1 无名信号量销毁
int sem_destroy(sem_t *sem);
sem
,信号量实例地址,不能为NULL- 返回,成功返回0,失败返回-1,错误码存于
error
中
sem_destroy
用于销毁一个已经初始化的无名信号量。销毁后的信号量处于未初始化状态,信号量的属性和控制块参数处于不可用状态。使用销毁函数需要注意几点:
- 已销毁的无名信号量,可以使用
sem_init
重新初始化使用 - 不能重复销毁已销毁的无名信号量
- 没有线程持有无名信号量时,且该信号量没有阻塞任何线程,才能销毁
3.6.2 有名信号量销毁
有名信号量销毁包括两个步骤,首先是关闭信号量,接着是分离信号量(删除)。调用分离函数销毁一个信号量,只是把信号量名称删除了,只有所有持有信号量的进程都关闭信号量后内核才会真正销毁信号量。
【1】关闭有名信号量
int sem_close(sem_t *sem);
name
,有名信号量名称- 返回,成功返回0,失败返回-1,错误码存于
error
中
当一个进程终止时,会对其占用的信号量执行此关闭操作,不论进程是主动终止还是非主动终止都会调用这个函数执行关闭信号量操作 。
【2】分离有名信号量
int sem_unlink(const char *name);
name
,有名信号量名称- 返回,成功返回0,失败返回-1,错误码存于
error
中
sem_unlink
函数会马上删除指定的信号量名称, 此时信号量本身并没有被删除,持有该信号量的进程仍能正常使用信号量,直到所有持有该信号量的进程关闭该信号量之后,内核才会真正删除信号量。
注:
进程创建一个有名信号量时,会在"/dev/shm"目录下生成一个“sem.xxx”的文件,进程没
open
该信号量,就增加该信号量文件的引用计数。当调用sem_unlink
函数时,"/dev/shm"目录下的“sem.xxx”文件会马上被删除,之后检查信号量文件引用计数,引用计数为0时则删除信号量,否则等待引用计数为0再删除信号量。
3. 7 写个例子
代码实现功能:
- 创建一个“生产者——消费者”模型
- 创建两个线程,一个线程负责产生数据,另一个读取数据并输出到终端
- 两个线程通过信号量同步
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
#include <stdlib.h>
#include <semaphore.h>
#include "pthread.h"
struct _buff_node
{
uint8_t buf[64];
uint32_t occupy_size;
};
/* 信号量 */
sem_t sem;
/* 共享缓存 */
static struct _buff_node s_buf;
void *thread0_entry(void *data)
{
/* 消费者线程 */
uint8_t i =0;
for (;;)
{
sem_wait(&sem);
if (s_buf.occupy_size != 0)
{
printf("thread0 read data:");
for (i=0; i<s_buf.occupy_size; i++)
{
printf("0x%02x ", s_buf.buf[i]);
}
printf("\r\n");
}
}
}
void *thread1_entry(void *data)
{
/* 生产者线程 */
uint8_t i =0;
for (;;)
{
s_buf.occupy_size = 0;
for (i = 0;i<8; i++)
{
s_buf.buf[i] = rand();
s_buf.occupy_size++;
}
printf("thread1 write %dByte data\n", s_buf.occupy_size);
sem_post(&sem);
sleep(1); /* 1秒产生数据 */
}
}
int main(int argc, char **argv)
{
pthread_t thread0,thread1,thread2;
void *retval;
sem_init(&sem, PTHREAD_PROCESS_PRIVATE, 0);
pthread_create(&thread0, NULL, thread0_entry, NULL);
pthread_create(&thread1, NULL, thread1_entry, NULL);
pthread_join(thread0, &retval);
pthread_join(thread1, &retval);
return 0;
}
输出结果
acuity@ubuntu:/mnt/hgfs/LSW/STHB/pthreads/sem$ gcc sem.c -o sem -lpthread
acuity@ubuntu:/mnt/hgfs/LSW/STHB/pthreads/sem$ ./sem
thread1 write 8Byte data
thread0 read data:0x67 0xc6 0x69 0x73 0x51 0xff 0x4a 0xec
thread1 write 8Byte data
thread0 read data:0x29 0xcd 0xba 0xab 0xf2 0xfb 0xe3 0x46
4 信号量属性
4.1 信号量类型
信号量根据作用对象不同,可以分为有名信号量和无名信号量。
- 有名信号量,用于进程间同步
- 无名信号量,用于线程间同步
4.2 无名信号量作用域
无名信号量用于线程间同步,与其他线程同步(互斥)机制一样,存在作用域范围。无名信号量作用域表示信号量的作用范围,分为进程内(创建者)作用域PTHREAD_PROCESS_PRIVATE
和跨进程作用域PTHREAD_PROCESS_SHARED
。进程内作用域只能用于进程内线程同步,跨进程可以用于系统所有线程间同步。
无名信号量作用域在调用sem_init
函数初始化时指定参数pshared
。
4.3 信号量持续性
持续性指的是信号量的生命周期范围,有名信号量和无名信号量持续性不同,无名信号量的持续性又与作用域相关。
- 有名信号量是随内核持续。当命名信号量创建后,即使当前没有进程打开某个信号量,它的值依然保持,直到内核重新自举或调用
sem_unlink
函数删除该信号量。
- 无名信号量作用域是进程内,此时信号量是随进程持续性,创建进程终止退出后信号量销毁。
- 无名信号量作用域是跨进程(系统内),此时信号量随内核持续性,因为共享内一直存在,信号量就一直存在。
5 总结
信号量是进程和线程间同步的一种机制,有名信号量用于进程同步,无名信号量用于线程间同步。信号量与互斥锁、读写锁、自旋锁、条件变量、屏障一样,都具有保护共享资源的功能,共享资源的线程互斥分别访问;同时信号量还具备“传递信息”的功能,使得线程间同步。信号量具有信号不丢失的可靠性,常用于“生产者——消费者”应用模型中。