信号量(Semaphore)是一种广泛使用的同步机制,用于控制对共享资源的访问,主要在操作系统和并发编程领域中得到应用,用来解决多个进程或线程间的同步与互斥问题。
与共享存储等不同,在linux中,信号量是用来协调进程或现成的执行的,并不承担传输数据的职责。
信号量本质上是一个非负整数变量,可以被用来控制对共享资源的访问,它主要用于两种目的:互斥和同步
(1)互斥(mutex):确保多个进程或线程不会同时访问临界区(即访问共享资源的代码区域)。
(2)同步(synchronization):协调多个进程或线程的执行程序,确保他们按照一定的顺序执行。
按用途分类可以分为:
(1)二进制信号量(或称作互斥锁):其值只能是0或1,主要用于实现互斥,即一次只允许一个线程进入临界区,通常用于控制共享资源的访问,避免竟态条件的产生,
(2)计数信号量:其值可以是任意非负整数,表示可用资源的数量。计数信号量允许多个线程根据可用资源的数量进入临界区。通常用于控制不同进程或线程执行的顺序,如消费者必须在生产者发送数据后才可以消费
按名称分为:
(1)无名信号量:无名信号量不是通过名称标识,而是直接通过sem_t结构的内存位置标识。无名信号量在使用前需要初始化,在不再需要时应该销毁。它们不需要像有名信号量那样进行创建和链接,因此设置起来更快,运行效率也更高。
(2)有名信号量:有名信号量在系统范围内是可见的,可以在任意进程之间进行通信。它们通过名字唯一标识,这使得不同的进程可以通过这个名字访问同一个信号量对象。
在当前Linux系统中,有名信号量在临时文件系统中的对应文件位于/dev/shm目录下,创建它们时可以像普通文件一样设置权限模式,限制不同用户的访问权限
操作:
信号量主要提供了两个操作:P操作和V操作。
P操作(Proberen,尝试):也称为等待操作(wait),用于减少信号量的值。如果信号量的值大于0,它就减1并继续执行;如果信号量的值为0,则进程或线程阻塞,直到信号量的值变为非零。
V操作(Verhogen,增加):也称为信号操作(signal),用于增加信号量的值。如果有其他进程或线程因信号量的值为0而阻塞,这个操作可能会唤醒它们。
一,无名信号量
------------------------unnamed_sem_bin_thread.c------------------------
#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>
#include <pthread.h>
sem_t unnamed_sem;
int shard_num = 0;
void *plusOne(void *argv) {
//信号量互斥
sem_wait(&unnamed_sem);
int tmp = shard_num + 1;
shard_num = tmp;
//信号量唤醒
sem_post(&unnamed_sem);
}
int main() {
//提前初始化信号量
/**
* sem_t *__sem:填写信号量的地址
* int __pshared:使用方式 0表示线程间使用 1表示进程间通讯
* unsigned int __value:初始值
* return:成功返回0 失败返回-1
* int sem_init (sem_t *__sem, int __pshared, unsigned int __value)
*/
sem_init(&unnamed_sem, 0, 1);
pthread_t tid[10000];
for (int i = 0; i < 10000; i++) {
pthread_create(tid + i, NULL, plusOne, NULL);
}
for (int i = 0; i < 10000; i++) {
pthread_join(tid[i], NULL);
}
printf("shard_num is %d\n", shard_num);
//销毁信号量
sem_destroy(&unnamed_sem);
return 0;
}
这个程序的逻辑是创建10000个线程,每个线程都对全局变量shard_num加一,最终结果应该是10000,但由于多个线程同时读写同一个变量,必须使用同步机制防止竟态条件。我们选择用未命名信号量来实现同步
1,调用sem_wait(&unnamed_sem),如果信号量值大于0(初始是1),则立即减1(变量0),线程继续执行。如果信号量值等于0,线程阻塞,直到其他线程调用sem_post。
2,进入临界区,只有一个线程能在此时执行shard_num + 1和赋值。其他9999个线程在sem_wait处排队等待
3,执行shard_num = tmp;安全修改共享变量,不会被其他线程干扰
4,调用sem_post(&unnamed_sem)信号量值加一(从零变回一),唤醒一个等待的线程(如果有),让它进入临界区
需要注意的是,线程比进程的资源共享程度更高,可以用于进程间通信的方式,通常也可以用于线程间通信
------------------------unnamed_sem_bin_process.c------------------------
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
char *shm_sem_name = "unnamed_sem_shm_sem";
char *shm_value_name = "unnamed_sem_shm_value";
// 创建内存共享对象
int sem_fd = shm_open(shm_sem_name, O_CREAT | O_RDWR, 0666);
int value_fd = shm_open(shm_value_name, O_CREAT | O_RDWR, 0666);
// 调整内存共享对象的大小
ftruncate(sem_fd, sizeof(sem_t));
ftruncate(value_fd, sizeof(int));
// 将内存共享对象映射到共享内存区域
sem_t *sem = mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE, MAP_SHARED, sem_fd, 0);
int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, value_fd, 0);
// 初始化信号量和共享变量的值
sem_init(sem, 1, 1);
*value = 0;
int pid = fork();
if (pid > 0)
{
sem_wait(sem);
int tmp = *value + 1;
sleep(1);
*value = tmp;
sem_post(sem);
// 等待子进程执行完毕
waitpid(pid, NULL, 0);
printf("this is father, child finished\n");
printf("the final value is %d\n", *value);
}
else if (pid == 0)
{
sem_wait(sem);
int tmp = *value + 1;
sleep(1);
*value = tmp;
sem_post(sem);
}
else
{
perror("fork");
}
// 父进程执行到这里,子进程已执行完毕,可以销毁信号量
if (pid > 0)
{
if (sem_destroy(sem) == -1)
{
perror("sem_destory");
}
}
// 无论父子进程都应该解除共享内存的映射,并关闭共享对象的文件描述符
if (munmap(sem, sizeof(sem)) == -1)
{
perror("munmap sem");
}
if (munmap(value, sizeof(int)) == -1)
{
perror("munmap value");
}
if (close(sem_fd) == -1)
{
perror("close sem");
}
if (close(value_fd) == -1)
{
perror("close value");
}
// 如果调用时别的进程仍在使用共享对象,则等待所有进程释放资源后,才会销毁相关资源。
// shm_unlink只能调用一次,这里在父进程中调用shm_unlink
if (pid > 0)
{
if (shm_unlink(shm_sem_name) == -1)
{
perror("father shm_unlink shm_sem_name");
}
if (shm_unlink(shm_value_name) == -1)
{
perror("father shm_unlink shm_value_name");
}
}
return 0;
}
这个程序我们希望创建一个被父子进程共享的整数变量(初始值为0),父进程和子进程各对其加一,使用信号量保证两个进程不会同时修改变量(互斥),最终输出the final value is 2
无名信号量被用于进程间通信时,需要注意两点:
1,sem_init()的第二个参数应设置为非零值,来告诉操作系统内核,这个信号量是用来进程间通信的,如果设置为0,则一个进程通过sem_post()释放的信号量无法被其它进程获取,会导致程序卡死。在初始化的时候,如果是进程间通讯的话,就写一,同时呢,要把它写在共享内存里,线程间通信的话就写零,同时呢,信号量是可以写到对应的属性里面,
2,信号量必须置于共享内存区域,以确保多个进程都可以访问,否则每个进程各自管理自己的信号量,后者并没有起到进程间通信的作用。
------------------------unnamed_sem_count_thread.c------------------------
#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>
#include <stdlib.h>
#include <time.h>
#include <pthread.h>
sem_t *full; // 表示"缓冲区中有数据可读"的数量
sem_t *empty; // 表示"缓冲区中空槽位"的数量
int shard_num;
int rand_num()
{
srand(time(NULL));
return rand();
}
void *producer(void *argv)
{
for (int i = 0; i < 5; i++)
{
sem_wait(empty);
printf("\n==========> 第 %d 轮数据传输 <=========\n\n", i + 1);
sleep(1);
shard_num = rand_num();
printf("producer has sent data\n");
sem_post(full);
}
}
void *consumer(void *argv)
{
for (int i = 0; i < 5; i++)
{
sem_wait(full);
printf("consumer has read data\n");
printf("the shard_num is %d\n", shard_num);
sleep(1);
sem_post(empty);
}
}
int main()
{
full = malloc(sizeof(sem_t));
empty = malloc(sizeof(sem_t));
sem_init(empty, 0, 1);
sem_init(full, 0, 0);
pthread_t producer_id, consumer_id;
pthread_create(&producer_id, NULL, producer, NULL);
pthread_create(&consumer_id, NULL, consumer, NULL);
pthread_join(producer_id, NULL);
pthread_join(consumer_id, NULL);
sem_destroy(empty);
sem_destroy(full);
return 0;
}
这个程序的运行逻辑是缓冲区full初始值为0,缓冲区empty初始值为一,我们的生产者调用sem_wait(empty);对empty进行减一操作,然后shard_num获取随机数然后调用sem_post(full);
对full进行加一操作,这时候full里的值为一,我们的消费者模型开始启动,调用sem_wait(full);使full减一,然后打印shard_num数字,然后调用sem_post(empty);使得empty数值加一,然后我们的生产者继续启动,按照这个顺序如此反复执行
| 函数 | 作用 | 使用建议 |
|---|---|---|
time(NULL) |
获取当前时间(秒),作为变化的种子 | 通常只用于 srand 的参数 |
srand(seed) |
设置随机数生成器的种子 | 整个程序只调用一次 ,通常在 main 开头 |
rand() |
生成一个伪随机整数(0 ~ RAND_MAX) | 在 srand 之后调用,可多次 |
1. time(NULL) 是"源头"
- 它提供一个随时间变化的整数(当前 Unix 时间戳)。
- 目的:打破确定性。因为计算机本身是确定性的,必须引入外部变化(如时间)才能让结果"看起来随机"。
没有
time(),你就只能用固定种子(如srand(123)),每次运行程序得到完全相同的随机数。
2. srand() 是"桥梁"
- 它接收
time(NULL)的返回值作为种子(seed)。 - 作用:配置底层的伪随机数生成器(PRNG)的初始状态。
- 一旦设置,后续所有
rand()调用都基于这个种子生成数列。
没有
srand(),rand()会使用默认种子(通常是 1),导致每次运行程序都输出相同序列。
3. rand() 是"产出"
- 它根据
srand()设置的种子,生成伪随机数序列。 - 每次调用
rand()会推进序列(内部状态更新),返回下一个数。
没有
rand(),前两者就没有意义------它们只是为rand()服务的。
------------------------unnamed_sem_count_process.c------------------------
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
char *shm_name = "unnamed_sem_shm";
// 创建内存共享对象
int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0666);
// 调整内存共享对象的大小
ftruncate(fd, sizeof(sem_t));
// 将内存共享对象映射到共享内存区域
sem_t *sem = mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 初始化信号量
sem_init(sem, 1, 0);
int pid = fork();
if (pid > 0)
{
//sem_wait sem=0无法减1 卡住
sem_wait(sem);
printf("this is father\n");
// 父进程等待子进程退出并回收资源
waitpid(pid, NULL, 0);
}
else if (pid == 0)
{
sleep(1);
printf("this is son\n");
//子进程释放信号量 sem+1 唤醒父进程
sem_post(sem);
}
else
{
perror("fork");
}
// 父进程执行到此处,子进程已执行完毕,可以销毁信号量
// 子进程执行到此处,父进程仍在等待信号量,此时销毁会导致未定义行为
// 只有父进程中应该销毁信号量
if (pid > 0)
{
if (sem_destroy(sem) == -1)
{
perror("father sem_destroy");
}
}
// 父子进程都应该解除映射,关闭文件描述符
if (munmap(sem, sizeof(sem)) == -1)
{
perror("munmap");
}
if (close(fd) == -1)
{
perror("close");
}
// shm_unlink只能调用一次,只在父进程中调用
if (pid > 0)
{
if (shm_unlink(shm_name) == -1)
{
perror("father shm_unlink");
}
}
return 0;
}
我们通过计数信号量控制父进程必须在子进程之后执行。如果没有信号量,子进程先休眠1s的情况下,父进程大概率是要先于子进程执行的(取决于操作系统的调度机制和策略),通过信号量,我们确保子进程先于父进程执行。
二,有名信号量
------------------------named_sem_bin.c------------------------
#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
char *sem_name = "/named_sem";
char *shm_name = "/named_sem_shm";
// 初始化有名信号量
sem_t *sem = sem_open(sem_name, O_CREAT, 0666, 1);
// 初始化内存共享对象
int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0666);
// 调整内存共享对象的大小
ftruncate(fd, sizeof(int));
// 将内存共享对象映射到内存空间
int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 初始化共享变量指针指向位置的值
*value = 0;
pid_t pid = fork();
if (pid < 0)
{
perror("fork");
}
sem_wait(sem);
int tmp = *value + 1;
sleep(1);
*value = tmp;
sem_post(sem);
// 每个进程都应该在使用完毕后关闭对信号量的连接
sem_close(sem);
if (pid > 0)
{
waitpid(pid, NULL, 0);
printf("子进程执行结束,value = %d\n", *value);
// 有名信号量的取消链接只能执行一次
sem_unlink(sem_name);
}
// 父子进程都解除内存共享对象的映射,并关闭相应的文件描述符
munmap(value, sizeof(int));
close(fd);
// 只有父进程应该释放内存共享对象
if (pid > 0)
{
if (shm_unlink(shm_name) == -1)
{
perror("shm_unlink");
}
}
return 0;
}
---------------------------named_sem_count.c------------------------
#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
char *sem_name = "/named_sem";
// 初始化有名信号量
sem_t *sem = sem_open(sem_name, O_CREAT, 0666, 0);
pid_t pid = fork();
if (pid > 0) {
sem_wait(sem);
printf("this is father\n");
// 等待子进程执行完毕
waitpid(pid, NULL, 0);
// 释放引用
sem_close(sem);
// 释放有名信号量
if(sem_unlink(sem_name) == -1) {
perror("sem_unlink");
}
} else if(pid == 0) {
sleep(1);
printf("this is son\n");
sem_post(sem);
// 释放引用
sem_close(sem);
} else
{
perror("fork");
}
return 0;
}
以下是无名信号量和有名信号量的模板:
1. 无名二进制信号量(线程互斥)
#include <semaphore.h>
sem_t sem;
sem_init(&sem, 0, 1); // pshared=0, value=1
sem_wait(&sem);
// 临界区
sem_post(&sem);
sem_destroy(&sem);
2. 无名计数信号量(线程资源控制)
#include <semaphore.h>
sem_t sem;
sem_init(&sem, 0, N); // pshared=0, value=N ≥ 0
sem_wait(&sem);
// 使用资源
sem_post(&sem);
sem_destroy(&sem);
3. 有名二进制信号量(进程互斥)
#include <semaphore.h>
sem_t *sem = sem_open("/name", O_CREAT, 0666, 1); // value=1
sem_wait(sem);
// 临界区
sem_post(sem);
sem_close(sem);
sem_unlink("/name"); // 仅需调用一次
4. 有名计数信号量(进程同步/资源)
#include <semaphore.h>
sem_t *sem = sem_open("/name", O_CREAT, 0666, N); // value=N ≥ 0
sem_wait(sem);
// 使用资源/等待事件
sem_post(sem);
sem_close(sem);
sem_unlink("/name"); // 仅需调用一次
以下是关于信号量的总结:
(1)可用于进程间通信的方式通常都可以用于线程间通信。
(2)无名信号量和有名信号量均可用于进程间通信,有名信号量是通过唯一的信号量名称在操作系统中唯一标识的。无名信号量用于进程间通信时必须将信号量存储在进程间可以共享的内存区域,作为内存地址直接在进程间共享。而内存区域的共享是通过内存共享对象的唯一名称来实现的。
(3)无名信号量和有名信号量都可以作为二进制信号量和计数信号量使用。
(4)二进制信号量和计数信号量的区别在于前者起到了互斥锁的作用,而后者起到了控制进程或线程执行顺序的作用。而不仅仅是信号量取值范围的差异。
(5)信号量是用来协调进程或线程协同工作的,本身并不用于传输数据。
(6)通常,从编码复杂度和效率的角度考虑,进程间通信使用有名信号量,线程间通信使用无名信号量。
(7)信号量用于跨进程通信时,要格外注意共享资源的创建和释放顺序,避免资源泄露或在不恰当的时机释放资源从而导致未定义行为。
(8)在生产环境的开发中,对于关键的步骤应当补充充分的错误处理,以便在错误发生时及时告警和响应。包括根据函数的返回值进行检查,结合使用 perror 或类似机制及时输出错误日志,以便快速排查和解决问题。此外,应确保适当释放资源以避免资源泄露。本文省略了这些步骤,这是为了使代码结构更加清晰以降低学习成本
三,线程池
#include <glib.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
// 任务函数
void task_func(gpointer data, gpointer user_data) {
int task_num = *(int*)data;
free(data);
printf("Executing task is %d...\n", task_num);
sleep(1);
printf("Task %d completed\n", task_num);
}
int main() {
// 创建线程池
GThreadPool *thread_pool = g_thread_pool_new(task_func, NULL, 5, TRUE, NULL);
// 向线程池添加任务
for (int i = 0; i < 10; i++) {
int *tmp = malloc(sizeof(int));
*tmp = i + 1;
g_thread_pool_push(thread_pool, tmp, NULL);
}
// 等待所有任务完成
g_thread_pool_free(thread_pool, FALSE, TRUE);
printf("All tasks completed\n");
return 0;
}
接下来我们来说一下这三个关于线程池的函数的参数:
g_thread_pool_new() ------ 创建线程池
GThreadPool *g_thread_pool_new (
GFunc func, // 任务执行函数
gpointer user_data, // 传递给 func 的固定参数
gint max_threads, // 最大线程数(-1 表示无限制)
gboolean exclusive, // 是否"独占"(TRUE 表示线程池私有)
GError **error // 错误信息(通常传 NULL)
);
| 参数 | 说明 |
|---|---|
func |
每个任务执行的函数,原型为 void (*GFunc)(gpointer data, gpointer user_data)。你传入 task_func。 |
user_data |
一个固定值 ,会作为第二个参数传给 func。你传 NULL,所以 task_func 中 user_data 为 NULL。 |
max_threads |
线程池最多创建多少个工作线程。你设为 5,即最多 5 个线程并发执行任务。 |
exclusive |
关键参数! <br> - TRUE:线程池是"独占"的,只能由当前线程 push 任务。<br> - FALSE:多个线程可安全地向池中 push 任务(内部加锁)。<br> 你用 TRUE,因为只在主线程 push,性能略高。 |
error |
用于返回错误。通常传 NULL,出错时函数返回 NULL。 |
g_thread_pool_push() ------ 提交任务
void g_thread_pool_push (
GThreadPool *pool, // 线程池指针
gpointer data, // 传递给任务函数的**任务数据**
GError **error // 错误(通常传 NULL)
);
| 参数 | 说明 |
|---|---|
pool |
由 g_thread_pool_new 返回的线程池。 |
data |
一个 void* 指针,作为第一个参数 传给 task_func。<br>你传的是 malloc 出来的 int*,值为任务编号。 |
error |
同上,通常 NULL。 |
g_thread_pool_free() ------ 销毁线程池
void g_thread_pool_free (
GThreadPool *pool, // 线程池指针
gboolean immediate, // 是否立即终止(不管任务是否完成)
gboolean wait // 是否等待线程结束
);
| 参数 | 说明 |
|---|---|
immediate |
- TRUE:立即终止 所有线程(即使任务未完成)→ 危险!<br> - FALSE:让线程完成当前任务后再退出。 |
wait |
- TRUE:阻塞当前线程 ,直到所有工作线程结束。<br> - FALSE:不等待,直接返回(可能造成主线程退出而工作线程还在跑)。 |
- 线程池创建:首先创建一个线程池,指定任务函数和其他参数。线程池会创建一定数量的线程,这些线程进入等待状态,准备执行任务,或在提交任务后才创建线程(取决于配置)。线程池中的所有任务执行的都是同一个任务函数。
- 任务队列:线程池维护一个任务队列。当我们向线程池提交任务时,任务会被放入这个队列中。实际上,放入任务队列的是我们在提交任务时传递的任务数据。
- 线程执行任务:线程池中的线程从任务队列中取出任务数据,然后调用任务函数,执行任务。执行完成后,线程不会退出,而是继续从任务队列中取下一个任务执行。如果没有待执行的任务,线程通常在等待一段时间后被回收(取决于具体的配置)。