MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言信号量的初始化操作

2021-04-113.7k 阅读

1. 信号量概述

在Linux多进程或多线程编程中,信号量(Semaphore)是一种重要的同步机制。它本质上是一个计数器,主要用于控制对共享资源的访问数量。例如,在一个系统中,有多个进程可能需要访问打印机这一共享资源,但打印机同一时间只能处理一个任务,此时信号量就可以用来限制同时访问打印机的进程数量为1。

信号量分为二值信号量(Binary Semaphore)和计数信号量(Counting Semaphore)。二值信号量的取值只有0和1,类似于互斥锁,用于保护临界区,确保同一时间只有一个进程或线程能进入临界区访问共享资源。而计数信号量的取值可以是任意非负整数,它可以用来控制同时访问共享资源的进程或线程数量。

2. Linux下信号量相关函数

在Linux环境中,使用C语言操作信号量主要涉及以下几个系统调用函数:

  • semget():用于创建一个新的信号量集或者获取一个已存在的信号量集的标识符。
  • semctl():对信号量进行各种控制操作,如初始化、删除等。
  • semop():用于对信号量进行P(等待)和V(释放)操作,以此来实现对共享资源的同步访问。

3. 信号量的初始化操作

3.1 semget()函数

函数原型

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);
  • 参数说明
    • key:是一个键值,它用来唯一标识一个信号量集。可以使用ftok()函数生成一个键值,也可以使用IPC_PRIVATE特殊值。如果使用IPC_PRIVATE,则创建一个只对创建进程及其子进程可见的信号量集。
    • nsems:指定信号量集中信号量的数量。例如,如果我们要控制多个共享资源的访问,可能就需要多个信号量,此时nsems的值就大于1。如果只需要一个信号量来控制一个共享资源,nsems就设为1。
    • semflg:是一组标志位,常见的有IPC_CREAT(如果信号量集不存在则创建)、IPC_EXCL(与IPC_CREAT一起使用,确保信号量集是新建的,若已存在则返回错误),还可以设置权限位,如0666表示所有用户都有读写权限。
  • 返回值:成功时返回信号量集的标识符(一个非负整数),失败时返回 -1,并设置errno来指示错误原因。

3.2 semctl()函数用于初始化

函数原型

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semctl(int semid, int semnum, int cmd, ...);
  • 参数说明
    • semid:是semget()函数返回的信号量集标识符。
    • semnum:指定要操作的信号量在信号量集中的编号,从0开始计数。如果信号量集中只有一个信号量,那么semnum为0。
    • cmd:指定要执行的命令。对于初始化操作,通常使用SETVAL命令,该命令用于设置指定信号量的值。
    • ...:这是一个可变参数列表。当cmdSETVAL时,需要提供一个union semun类型的参数。union semun的定义如下:
union semun {
    int val;                /* Value for SETVAL */
    struct semid_ds *buf;   /* Buffer for IPC_STAT, IPC_SET */
    unsigned short *array;  /* Array for GETALL, SETALL */
    struct seminfo *__buf;  /* Buffer for IPC_INFO (Linux-specific) */
};

在初始化时,我们关心的是val成员,它用来设置信号量的初始值。

3.3 初始化代码示例

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

// 定义union semun
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
    struct seminfo *__buf;
};

int main() {
    key_t key;
    int semid;
    union semun arg;

    // 使用ftok生成键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok");
        return 1;
    }

    // 创建信号量集,其中只包含一个信号量
    semid = semget(key, 1, IPC_CREAT | 0666);
    if (semid == -1) {
        perror("semget");
        return 1;
    }

    // 初始化信号量的值为1
    arg.val = 1;
    if (semctl(semid, 0, SETVAL, arg) == -1) {
        perror("semctl SETVAL");
        return 1;
    }

    printf("信号量初始化成功,信号量ID: %d\n", semid);

    // 后续可以在这里进行信号量的P和V操作

    // 删除信号量集
    if (semctl(semid, 0, IPC_RMID) == -1) {
        perror("semctl IPC_RMID");
        return 1;
    }

    return 0;
}

在上述代码中:

  • 首先使用ftok()函数生成一个键值key,该键值基于当前目录(.)和字符'a'生成。
  • 然后调用semget()函数创建一个信号量集,其中只包含一个信号量,并设置权限为0666
  • 接着通过union semun类型的变量arg设置信号量的初始值为1,并使用semctl()函数的SETVAL命令完成初始化。
  • 最后,在程序结束前使用semctl()函数的IPC_RMID命令删除信号量集,释放系统资源。

4. 初始化操作的本质与应用场景

4.1 初始化本质

信号量初始化的本质是为了确定共享资源的初始可用数量。对于二值信号量,初始值设为1,表示共享资源初始时可用,进程可以获取信号量进入临界区访问共享资源。而对于计数信号量,初始值设为共享资源的数量,例如有5个相同的网络连接资源,那么计数信号量的初始值就设为5,这样最多可以有5个进程同时获取信号量使用网络连接资源。

从系统底层角度看,信号量在操作系统内核中以数据结构的形式存在,初始化操作就是对这个数据结构中的计数器进行赋值。这个计数器的值决定了后续进程或线程对共享资源的访问情况。

4.2 应用场景

  • 进程同步:在多进程应用中,多个进程可能需要访问共享内存区域。通过初始化一个二值信号量并将其值设为1,每个进程在访问共享内存前先获取信号量(P操作),访问结束后释放信号量(V操作),这样可以确保同一时间只有一个进程能访问共享内存,避免数据竞争。
  • 资源控制:在服务器程序中,可能有多个客户端请求访问有限的资源,如数据库连接。可以使用计数信号量,将其初始值设为数据库连接的最大数量。每个客户端请求到达时,先获取信号量(P操作),如果信号量值大于0,则表示有可用连接,客户端可以获取连接进行数据库操作;操作完成后释放信号量(V操作),这样可以有效控制同时使用数据库连接的客户端数量,防止数据库过载。

5. 初始化过程中的常见问题及解决方法

5.1 键值冲突

  • 问题描述:如果使用ftok()函数生成键值,可能会因为不同程序使用相同的路径名和项目ID而导致键值冲突。当两个不同的信号量集使用相同的键值时,semget()函数可能会获取到错误的信号量集,导致后续操作出现问题。
  • 解决方法:尽量使用唯一的路径名和项目ID来生成键值。可以使用程序特定的目录和独特的项目ID,例如结合程序的安装路径和程序的版本号生成键值。另外,也可以使用IPC_PRIVATE键值来创建只在特定进程及其子进程间共享的信号量集,这样可以避免键值冲突问题,但需要通过进程间通信机制(如管道、共享内存等)来传递信号量集的标识符。

5.2 权限设置不当

  • 问题描述:在semget()函数中设置信号量集的权限时,如果设置不当,可能会导致进程无法访问信号量集。例如,设置的权限过于严格,其他需要访问信号量集的进程没有相应的权限,就会在调用semctl()semop()函数时返回错误。
  • 解决方法:根据实际需求合理设置信号量集的权限。如果是多个进程间共享信号量集,一般设置为0666可以保证所有用户都有读写权限。但在一些安全要求较高的场景下,可能需要更精细的权限设置,如只允许特定用户或组访问。可以使用chmod命令类似的方式来调整信号量集的权限,通过semctl()函数的IPC_SET命令来修改信号量集的属性,包括权限设置。

5.3 信号量未正确初始化

  • 问题描述:在调用semctl()函数进行初始化时,如果参数设置错误,如semnum指定错误、union semun类型的参数成员赋值错误等,可能会导致信号量未正确初始化。这会使得后续的信号量操作(如P和V操作)出现逻辑错误,如共享资源被过度访问或无法正常访问。
  • 解决方法:仔细检查semctl()函数的参数设置。确保semnum是正确的信号量编号,对于union semun类型的参数,根据不同的cmd命令正确赋值相应的成员。在初始化前,可以先检查信号量集是否已经存在且正确初始化,避免重复初始化或错误初始化。可以通过semctl()函数的GETVAL命令获取信号量当前的值,判断其是否符合预期。

6. 不同场景下的初始化策略

6.1 单进程内多线程使用信号量

在单进程内多线程编程中,使用信号量进行同步时,初始化策略与多进程略有不同。由于线程共享进程的地址空间,信号量集可以在进程启动时创建并初始化,所有线程都可以访问这个信号量集。

  • 初始化代码示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

// 定义union semun
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
    struct seminfo *__buf;
};

// 信号量ID
int semid;

// 线程函数
void* thread_function(void* arg) {
    // 这里可以进行信号量的P操作
    // 例如:struct sembuf sem_op; sem_op.sem_num = 0; sem_op.sem_op = -1; sem_op.sem_flg = 0; semop(semid, &sem_op, 1);
    printf("线程正在执行\n");
    // 这里可以进行信号量的V操作
    // 例如:sem_op.sem_op = 1; semop(semid, &sem_op, 1);
    return NULL;
}

int main() {
    key_t key;
    union semun arg;

    // 使用ftok生成键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok");
        return 1;
    }

    // 创建信号量集,其中只包含一个信号量
    semid = semget(key, 1, IPC_CREAT | 0666);
    if (semid == -1) {
        perror("semget");
        return 1;
    }

    // 初始化信号量的值为1
    arg.val = 1;
    if (semctl(semid, 0, SETVAL, arg) == -1) {
        perror("semctl SETVAL");
        return 1;
    }

    pthread_t thread;
    if (pthread_create(&thread, NULL, thread_function, NULL) != 0) {
        perror("pthread_create");
        return 1;
    }

    if (pthread_join(thread, NULL) != 0) {
        perror("pthread_join");
        return 1;
    }

    // 删除信号量集
    if (semctl(semid, 0, IPC_RMID) == -1) {
        perror("semctl IPC_RMID");
        return 1;
    }

    return 0;
}

在这个示例中,主线程创建并初始化信号量集,然后创建一个新线程。线程函数中可以进行信号量的P和V操作,与多进程场景不同的是,这里不需要考虑进程间传递信号量集标识符的问题,因为线程共享进程的地址空间。

6.2 多进程父子进程间共享信号量

在多进程编程中,父子进程间共享信号量是一种常见的场景。通常父进程创建信号量集并初始化,然后通过fork()函数创建子进程,子进程可以继承父进程的信号量集标识符,从而共享信号量。

  • 初始化代码示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <unistd.h>

// 定义union semun
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
    struct seminfo *__buf;
};

int main() {
    key_t key;
    int semid;
    union semun arg;
    pid_t pid;

    // 使用ftok生成键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok");
        return 1;
    }

    // 创建信号量集,其中只包含一个信号量
    semid = semget(key, 1, IPC_CREAT | 0666);
    if (semid == -1) {
        perror("semget");
        return 1;
    }

    // 初始化信号量的值为1
    arg.val = 1;
    if (semctl(semid, 0, SETVAL, arg) == -1) {
        perror("semctl SETVAL");
        return 1;
    }

    pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // 子进程
        // 这里可以进行信号量的P操作
        // 例如:struct sembuf sem_op; sem_op.sem_num = 0; sem_op.sem_op = -1; sem_op.sem_flg = 0; semop(semid, &sem_op, 1);
        printf("子进程正在执行\n");
        // 这里可以进行信号量的V操作
        // 例如:sem_op.sem_op = 1; semop(semid, &sem_op, 1);
        exit(0);
    } else {
        // 父进程
        wait(NULL);
        // 删除信号量集
        if (semctl(semid, 0, IPC_RMID) == -1) {
            perror("semctl IPC_RMID");
            return 1;
        }
    }

    return 0;
}

在这个示例中,父进程创建并初始化信号量集,然后通过fork()创建子进程。子进程可以直接使用父进程创建的信号量集进行同步操作,无需额外的信号量集标识符传递机制。

6.3 多进程非父子进程间共享信号量

对于多进程中非父子进程间共享信号量,需要通过一些进程间通信机制来传递信号量集的标识符。常见的方法是使用共享内存来存储信号量集标识符,或者通过文件系统来记录信号量集标识符。

  • 使用共享内存传递信号量集标识符的示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include <unistd.h>

// 定义union semun
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
    struct seminfo *__buf;
};

int main() {
    key_t key, shm_key;
    int semid, shmid;
    union semun arg;
    int *shm_ptr;
    pid_t pid;

    // 使用ftok生成信号量集键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok");
        return 1;
    }

    // 使用ftok生成共享内存键值
    shm_key = ftok(".", 'b');
    if (shm_key == -1) {
        perror("ftok for shm");
        return 1;
    }

    // 创建信号量集,其中只包含一个信号量
    semid = semget(key, 1, IPC_CREAT | 0666);
    if (semid == -1) {
        perror("semget");
        return 1;
    }

    // 初始化信号量的值为1
    arg.val = 1;
    if (semctl(semid, 0, SETVAL, arg) == -1) {
        perror("semctl SETVAL");
        return 1;
    }

    // 创建共享内存
    shmid = shmget(shm_key, sizeof(int), IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }

    // 附加共享内存到进程地址空间
    shm_ptr = (int *)shmat(shmid, NULL, 0);
    if (shm_ptr == (void *)-1) {
        perror("shmat");
        return 1;
    }

    *shm_ptr = semid;

    pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    } else if (pid == 0) {
        // 子进程
        int child_semid = *shm_ptr;
        // 这里可以进行信号量的P操作
        // 例如:struct sembuf sem_op; sem_op.sem_num = 0; sem_op.sem_op = -1; sem_op.sem_flg = 0; semop(child_semid, &sem_op, 1);
        printf("子进程正在执行,使用信号量ID: %d\n", child_semid);
        // 这里可以进行信号量的V操作
        // 例如:sem_op.sem_op = 1; semop(child_semid, &sem_op, 1);
        exit(0);
    } else {
        // 父进程
        wait(NULL);
        // 分离共享内存
        if (shmdt(shm_ptr) == -1) {
            perror("shmdt");
            return 1;
        }
        // 删除共享内存
        if (shmctl(shmid, IPC_RMID, NULL) == -1) {
            perror("shmctl IPC_RMID");
            return 1;
        }
        // 删除信号量集
        if (semctl(semid, 0, IPC_RMID) == -1) {
            perror("semctl IPC_RMID");
            return 1;
        }
    }

    return 0;
}

在这个示例中,父进程创建信号量集并初始化,同时创建共享内存。父进程将信号量集标识符存储在共享内存中,然后通过fork()创建子进程。子进程从共享内存中获取信号量集标识符,从而可以使用该信号量集进行同步操作。操作完成后,父进程负责分离和删除共享内存以及删除信号量集。

7. 信号量初始化与其他同步机制的比较

7.1 与互斥锁比较

  • 相同点:互斥锁和二值信号量都可以用于保护临界区,确保同一时间只有一个进程或线程能进入临界区访问共享资源。它们在实现进程或线程同步方面有相似的功能。
  • 不同点:互斥锁通常用于线程同步,在同一进程内的线程间使用,其实现机制更轻量级。而信号量不仅可以用于线程同步,还广泛应用于多进程同步。信号量可以是计数信号量,能控制多个共享资源的访问数量,这是互斥锁所不具备的功能。在初始化方面,互斥锁一般使用pthread_mutex_init()函数初始化,而信号量使用semget()semctl()函数初始化,涉及到系统调用和更复杂的参数设置。

7.2 与条件变量比较

  • 相同点:条件变量和信号量都可以用于线程或进程间的同步,它们都能使线程或进程在某些条件满足时被唤醒。
  • 不同点:条件变量通常与互斥锁配合使用,用于线程等待某个条件成立。线程在等待条件变量时会释放持有的互斥锁,避免死锁。而信号量更侧重于控制共享资源的访问数量。在初始化方面,条件变量使用pthread_cond_init()函数初始化,信号量则是通过semget()semctl()函数初始化。条件变量的使用场景更侧重于等待某个逻辑条件的变化,而信号量更侧重于资源数量的控制。

8. 信号量初始化操作的优化

8.1 批量初始化

在一些情况下,如果需要创建多个信号量组成信号量集,可以考虑批量初始化。例如,在一个需要管理多个相同类型资源的系统中,可能需要创建多个计数信号量来控制这些资源的访问。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

// 定义union semun
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
    struct seminfo *__buf;
};

int main() {
    key_t key;
    int semid;
    union semun arg;
    unsigned short values[5]; // 假设有5个信号量

    // 使用ftok生成键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok");
        return 1;
    }

    // 创建信号量集,包含5个信号量
    semid = semget(key, 5, IPC_CREAT | 0666);
    if (semid == -1) {
        perror("semget");
        return 1;
    }

    // 初始化每个信号量的值
    for (int i = 0; i < 5; i++) {
        values[i] = 1;
    }
    arg.array = values;
    if (semctl(semid, 0, SETALL, arg) == -1) {
        perror("semctl SETALL");
        return 1;
    }

    printf("信号量批量初始化成功,信号量ID: %d\n", semid);

    // 后续可以在这里进行信号量的操作

    // 删除信号量集
    if (semctl(semid, 0, IPC_RMID) == -1) {
        perror("semctl IPC_RMID");
        return 1;
    }

    return 0;
}

在这个示例中,使用semctl()函数的SETALL命令,通过union semunarray成员一次性初始化多个信号量的值,相比于逐个初始化,可以提高效率。

8.2 避免重复初始化

在复杂的多进程或多线程程序中,可能会出现信号量被重复初始化的情况,这会导致逻辑错误。为了避免这种情况,可以在初始化前检查信号量是否已经被正确初始化。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

// 定义union semun
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
    struct seminfo *__buf;
};

int main() {
    key_t key;
    int semid;
    union semun arg;

    // 使用ftok生成键值
    key = ftok(".", 'a');
    if (key == -1) {
        perror("ftok");
        return 1;
    }

    // 创建信号量集,其中只包含一个信号量
    semid = semget(key, 1, IPC_CREAT | 0666);
    if (semid == -1) {
        perror("semget");
        return 1;
    }

    // 检查信号量是否已经初始化
    int current_value = semctl(semid, 0, GETVAL);
    if (current_value == -1) {
        perror("semctl GETVAL");
        return 1;
    }
    if (current_value != 1) {
        // 未初始化,进行初始化
        arg.val = 1;
        if (semctl(semid, 0, SETVAL, arg) == -1) {
            perror("semctl SETVAL");
            return 1;
        }
    }

    printf("信号量已处理,信号量ID: %d\n", semid);

    // 后续可以在这里进行信号量的操作

    // 删除信号量集
    if (semctl(semid, 0, IPC_RMID) == -1) {
        perror("semctl IPC_RMID");
        return 1;
    }

    return 0;
}

在这个示例中,通过semctl()函数的GETVAL命令获取信号量当前的值,判断其是否为预期的初始值。如果不是,则进行初始化操作,这样可以避免重复初始化带来的问题。

通过合理应用这些优化策略,可以提高信号量初始化操作的效率和稳定性,使得基于信号量的同步机制在多进程或多线程程序中更加可靠地运行。