进程与线程-----C语言经典题目(9)

发布于:2025-05-01 ⋅ 阅读:(59) ⋅ 点赞:(0)

一、线程创建函数是什么,函数指针的作用?

        pthread_create。函数原型如下:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);

四个参数:

        1.pthread_t *thread:用于存储新创建线程的标识符。

        2.const  pthread_attr_t  *attr:可对线程属性进行设置,若设为NULL,则使用默认属性。

        3.void  *(*start_routine)(void  *):函数指针,指向线程启动时要执行的函数。

        4.void  *arg:作为参数传递给 start_routine 函数。

若线程创建成功,函数返回0,否则返回错误码。

函数指针的作用:

        它明确了线程启动后要执行的函数。就pthread_create 而言:

        1.函数必须返回void  *类型。

        2.函数必须接受一个void  *类型的参数。

        3.借助函数指针,能实现线程执行不同的任务,极大地提升了代码的灵活性。

/**
 * 线程执行的函数,作为pthread_create的回调函数
 * 参数:
 *   arg - 传入的参数,类型为void*,需强制转换为实际类型
 * 返回值:
 *   线程执行完毕后的返回值,类型为void*
 */
void* thread_function(void* arg) 
{
    // 将参数从void*转换为int*
    int* n = (int*)arg;
    // 打印接收到的参数值
    printf("线程运行中,参数为 %d\n", *n);
    // 线程执行完毕,返回NULL
    return NULL;
}

int main() 
{
    pthread_t thread_id;  // 线程标识符,用于后续操作线程
    int parameter = 42;   // 传递给线程的参数

    /**
     * 创建新线程并执行thread_function
     * 参数:
     *   &thread_id - 存储新线程的标识符
     *   NULL - 使用默认线程属性
     *   thread_function - 线程启动后执行的函数
     *   &parameter - 传递给thread_function的参数
     * 返回值:
     *   0 - 成功,非零 - 失败
     */
    int result = pthread_create(&thread_id, NULL, thread_function, &parameter);
    // 检查线程创建是否成功
    if (result != 0) 
    {
        perror("线程创建失败");  // 打印错误信息
        return 1;                // 程序异常退出
    }

    /**
     * 等待指定线程结束
     * 参数:
     *   thread_id - 要等待的线程标识符
     *   NULL - 不获取线程的返回值
     * 作用:
     *   阻塞主线程,直到指定线程执行完毕
     */
    pthread_join(thread_id, NULL);
    // 线程结束后,主线程继续执行
    printf("主线程继续执行\n");

    return 0;  // 程序正常退出
}

函数指针的优势:

        动态调度:可以根据实际需求来选择要执行的函数。

        回调机制:能够实现事件驱动编程,当特定事件发生时,调用相应的处理函数。

二、什么是线程的分离属性,什么时候用?

        线程的分离属性是一种线程状态设置,它能改变线程结束时的资源回收机制。

分离属性的定义:

        当线程被设置为分离状态后,一旦线程执行完毕,系统会自动回收它所占用的资源,无需其他线程调用pthread_join()来等待。

分离属性的设置方式:

        创建线程时设置:在初始化线程属性对象 pthread_attr_t时,把分离状态设置为 PTHREAD_CREATE_DETACHED。

        线程运行时设置:在线程运行过程中,调用 pthread_detach(pthread_self())让线程自我分离。

适用场景:

        后台任务:对于不需要返回结果,也不用等待其结束的后台任务,比如日志记录、监控等。        

        避免资源泄漏:当创建大量短期线程时,为了防止忘记调用 pthread_join()而导致资源泄漏时。

        简化编程模型:再不需要同步的时候,使用分离属性可以简化代码。

代码示例:

void* thread_function(void* arg) 
{
    printf("线程正在运行...\n");
    sleep(2);
    printf("线程执行完毕\n");
    return NULL;
}

int main() 
{
    pthread_t thread;
    pthread_attr_t attr;

    // 初始化线程属性
    pthread_attr_init(&attr);
    // 设置线程为分离状态
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

    // 创建分离线程
    if (pthread_create(&thread, &attr, thread_function, NULL) != 0) 
    {
        perror("线程创建失败");
        return 1;
    }

    // 释放线程属性资源
    pthread_attr_destroy(&attr);

    // 主线程可以继续执行其他任务
    printf("主线程继续执行...\n");
    // 主线程不需要等待分离线程结束

    // 主线程退出,但不会影响分离线程的执行
    pthread_exit(NULL);
}

三、什么是互斥和同步,linux中提供对应的机制名称。

互斥:

        定义:确保同一时间只有一个线程 / 进程访问共享资源(变量、文件),防止数据竞争和不一致问题。

        linux 机制:

                互斥锁:通过pthread_mutex_t 实现,使用 pthread_mutex_lock()和pthread_mutex_unlock()保护临界区。

pthread_mutex_t mutex;

void* thread_function(void* arg) 
{
    pthread_mutex_lock(&mutex);    // 加锁
    // 临界区代码(访问共享资源)
    pthread_mutex_unlock(&mutex);  // 解锁
    return NULL;
}

                信号量:通过sem_t 实现,初始值为 1 时可作为互斥锁(如 sem_wait()和sem_post())

同步:

        定义:协调多个线程 / 进程的执行顺序,确保它们按预期顺序访问资源或执行操作。

        linux 机制:

                条件变量:通过 pthread_cond_t 实现,配合互斥锁使用(如pthread_cond_wait()和 pthread_cond_signal())。

// 声明互斥锁,用于保护共享变量ready
pthread_mutex_t mutex;
// 声明条件变量,用于线程间的等待-通知机制
pthread_cond_t cond;
// 共享标志位:0表示数据未准备好,1表示数据已就绪
int ready = 0;

// 生产者线程函数:准备数据并通知消费者
void* producer(void* arg) 
{
    // 加锁:进入临界区,防止多线程竞争
    pthread_mutex_lock(&mutex);
    
    // 设置标志位,表示数据已准备好
    ready = 1;
    
    // 发送信号:唤醒可能正在等待的消费者线程
    pthread_cond_signal(&cond);  // 通知消费者
    
    // 解锁:离开临界区,允许其他线程访问共享资源
    pthread_mutex_unlock(&mutex);
    
    return NULL;
}

// 消费者线程函数:等待数据就绪后进行处理
void* consumer(void* arg) 
{
    // 加锁:进入临界区
    pthread_mutex_lock(&mutex);
    
    // 循环检查数据是否就绪(防止虚假唤醒)
    while (!ready) 
    {
        // 释放锁并进入等待状态,直到收到条件变量通知
        // 收到通知后自动重新加锁
        pthread_cond_wait(&cond, &mutex);  // 等待生产者通知
    }
    
    // 此时ready为1,数据已就绪,可进行处理
    // 处理数据
    
    // 解锁:离开临界区
    pthread_mutex_unlock(&mutex);
    
    return NULL;
}

                信号量:初始值大于 1 时可用于限制并发访问数量。

                屏障:通过 pthread_barrier_t 实现,使多个线程等待至所有线程都到达某一点后再继续执行。

机制 用途 关键函数
互斥锁 包含共享资源,防止竞争 pthread_mutex_t
条件变量 线程间等待 - 通知机制 pthread_cond_t
信号量 限制并发访问或实现同步 sem_t
屏障 多线程同步点 pthread_barrier_t

四、什么是死锁?死锁产生的四个必要条件。如何避免死锁发生?

死锁的定义

        死锁指的是两个或多个线程(进程)因争夺资源而陷入无限等待的状态。比如说:线程A持有资源 X ,并且请求获取资源 Y;而线程 B 持有资源 Y,同时请求获取资源 X。此时,两个线程都在等待对方释放资源,就会造成程序无法继续运行。

死锁产生的四个必要条件

        1.互斥条件:资源在同一时间只能被一个线程(进程)使用。比如打印机,同一时刻只能有一个进程使用它。

        2.占有并等待条件:线程(进程)已经持有了至少一个资源,还在请求其他资源,而且在等待新资源的过程中不会释放已有的资源。

        3.不可抢占条件:线程(进程)持有的资源只能由自己释放,其他线程(进程)不能强行抢占。

        4.循环等待条件:在资源的请求链中,每个线程(进程)都在等待下一个线程(进程)所占用的资源,形成了一个循环。

如何避免死锁:

1.破坏“占有并等待”条件

        一次性获取所有资源:在获取第一个资源之前,先检查并获取所有需要的资源。要是有一个资源无法获取,就释放已持有的资源。

pthread_mutex_lock(&mutex1);

if (pthread_mutex_trylock(&mutex2) != 0) 
{
    pthread_mutex_unlock(&mutex1); // 释放已持有的资源
    // 进行错误处理
}

        使用原子操作:借助 pthread_mutex_timedlock 等函数,在获取资源失败时返回错误,而不是一直等待。                  

2.破坏“不可抢占”事件

        设置超时机制:使用 pthread_mutex_timedlock 来尝试获取锁,若超时则释放已持有的锁。

struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 1; // 设置1秒的超时时间

if (pthread_mutex_timedlock(&mutex, &ts) != 0) 
{
    // 超时处理,释放其他资源
}

3.破坏“循环等待”条件

        对资源进行排序:给所有资源分配一个全局的序号,规定线程(进程)必须按照序号从小到大的顺序来获取资源。

// 假设mutex1的序号小于mutex2
pthread_mutex_lock(&mutex1);
pthread_mutex_lock(&mutex2); // 必须按顺序获取
// 修改thread2,使其与thread1按相同顺序获取锁
void* thread2(void* arg) 
{
    pthread_mutex_lock(&mutex1); // 先获取mutex1
    sleep(1);
    pthread_mutex_lock(&mutex2); // 再获取mutex2
    // 执行操作
    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);
    return NULL;
}

五、进程间通信方式有哪些?

管道

        半双工,数据只能沿着一个方向流动,它仅适用于具有亲缘关系的进程,像父子进程之间。

int main() 
{
    int fd[2];
    pipe(fd);  // 创建管道
    // fd[0] 用于读,fd[1] 用于写
    return 0;
}

命名管道

        突破了管道只能在亲缘进程间通信的限制,它允许无亲缘关系的进程进行通信。

int main() 
{
    mkfifo("fifo", 0666);  // 创建命名管道
    // 读写操作和普通文件一样
    return 0;
}

消息队列

        以消息为单位。把数据发送到队列中,进程可以从队列里读取消息,实现进程间通信。

struct msgbuf 
{
    long mtype;       // 消息类型
    char mtext[100];  // 消息内容
};

int main() 
{
    int msgid = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);  // 创建消息队列
    // msgsnd 发送消息,msgrcv 接收消息
    return 0;
}

共享内存

        共享内存让多个进程可以访问同一块物理内存区域,这是最快的一种 IPC 方式。

int main() 
{
    int shmid = shmget(IPC_PRIVATE, 1024, 0666 | IPC_CREAT);  // 创建共享内存
    char *data = (char*)shmat(shmid, NULL, 0);  // 连接共享内存
    // 直接读写 data
    shmdt(data);  // 断开连接
    shmctl(shmid, IPC_RMID, NULL);  // 删除共享内存
    return 0;
}

信号量

        信号量主要用于进程间的同步,它并非直接用于数据交换,而是用来协调对共享资源的访问。

int main() 
{
    int semid = semget(IPC_PRIVATE, 1, 0666 | IPC_CREAT);  // 创建信号量
    // semop 操作信号量
    return 0;
}

套接字

        套接字可用于不同主机间的进程通信,当然也能用于本地进程通信。

int main() 
{
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);  // 创建套接字
    // 连接、发送、接收等操作
    return 0;
}

信号

        异步,用于通知进程某个事件已经发生。

void handler(int signum) 
{
    // 信号处理函数
}

int main() 
{
    signal(SIGINT, handler);  // 注册信号处理函数
    // 进程继续执行其他任务
    return 0;
}

六、有名管道的读阻塞,写阻塞是什么?管道破裂的触发条件?

读阻塞

        当进程对有名管道执行读操作时,读阻塞有两种:

1.管道无数据可读时:要是管道里没有数据,进程就会进入阻塞状态,一直等到有数据被写入管道。

2.所有写端关闭时:一旦所有写端都关闭了,读操作会立即返回 0 ,这说明读到了文件结束符(EOF),此时不会发生阻塞。

int main() 
{
    // 以只读模式打开名为 "myfifo" 的有名管道
    // O_RDONLY 表示只读模式
    int fd = open("myfifo", O_RDONLY);
    
    // 检查 open 函数是否执行成功,若失败则输出错误信息并退出程序
    if (fd == -1) 
    { 
        perror("open"); 
    
        return 1; 
    }

    // 定义一个字符数组用于存储从管道中读取的数据
    char buffer[100];

    // 调用 read 函数从管道中读取数据到 buffer 中
    // 如果管道中没有数据,read 函数会阻塞,直到有数据写入管道
    int bytes = read(fd, buffer, sizeof(buffer));

    // 输出读取到的字节数和读取到的数据内容
    printf("Read %d bytes: %s\n", bytes, buffer);

    // 关闭打开的管道文件描述符
    close(fd);

    return 0;
}

写阻塞

        进程对有名管道执行写操作时,写阻塞的情况如下:

1.管道已满时:当管道的缓冲区满了(默认大小通常是64kb),进程就会阻塞,直到有数据被读取,腾出空间。

2.没有读端打开时:要是没有进程打开管道进行读操作,写进程会收到 SIGPIPE 信号,默认情况下这个信号会导致进程终止。但也可以捕获这个信号并进行处理。

int main() 
{
    // 以只写模式打开名为 "myfifo" 的有名管道
    // O_WRONLY 表示只写模式
    int fd = open("myfifo", O_WRONLY);

    // 检查 open 函数是否执行成功,若失败则输出错误信息并退出程序
    if (fd == -1) 
    { 
        perror("open"); 
    
        return 1; 
    }

    // 调用 write 函数向管道中写入数据
    // 如果管道已满,write 函数会阻塞,直到有数据被读取腾出空间
    // 如果没有读端打开,write 函数可能会触发 SIGPIPE 信号
    write(fd, "Hello, FIFO!", 12);
 
    // 关闭打开的管道文件描述符
    close(fd);

    return 0;
}

管道破裂的触发条件

        当进程尝试向一个没有读端的管道写入数据时,就会触发管道破裂。

1.所有读端关闭:如果所有打开管道进行读操作的进程都关闭了管道,此时写进程再执行写操作,就会触发 SIGPIPE 信号。

2.信号处理:默认情况下,SIGPIPE 信号会使进程终止。不过,进程可以捕获这个信号并进行自定义处理,例如忽略它或者执行特定的操作。

3.返回值:如果进程忽略了 SIGPIPE 信号,写操作会失败,并且返回 -1,同时 errno 会被设置为 EPIPE。

// 自定义的信号处理函数,用于处理 SIGPIPE 信号
// 当接收到 SIGPIPE 信号时,会输出提示信息
void handle_sigpipe(int signum) 
{
    printf("Caught SIGPIPE, but ignoring it!\n");
}

int main() 
{
    // 注册信号处理函数,将 SIGPIPE 信号的处理方式设置为 handle_sigpipe 函数
    signal(SIGPIPE, handle_sigpipe);

    // 以只写模式打开名为 "myfifo" 的有名管道
    int fd = open("myfifo", O_WRONLY);

    // 检查 open 函数是否执行成功,若失败则输出错误信息并退出程序
    if (fd == -1) 
    { 
        perror("open"); 
        return 1; 
    }

    // 调用 write 函数向管道中写入数据
    // 如果没有读端打开,write 函数会触发 SIGPIPE 信号
    int result = write(fd, "Test", 4);
   
    // 检查 write 函数的返回值,如果返回 -1 且 errno 为 EPIPE,表示管道破裂
    if (result == -1 && errno == EPIPE) 
    {
        printf("Pipe is broken!\n");
    }
   
    // 关闭打开的管道文件描述符
    close(fd);

    return 0;
}

七、无名管道和有名管道的区别?

        无名管道(匿名管道)和有名管道(命名管道)都用于进程间通信(IPC)。

1.命名和使用范围

        无名管道:没有名字,只能用于具有亲缘关系和进程间通信,像父子进程或者兄弟进程。

        有名管道:有文件名,存储于文件系统中,不同的进程只要能访问该文件,就可以利用这个有名管道进行通信,进程间不一定要有亲缘关系。

2.创建方式

        无名管道:使用pipe()系统调用创建,这个函数会创建一对文件描述符,一个用于读,一个用于写。

int main() 
{
    int pipefd[2];

    if (pipe(pipefd) == -1) 
    {
        perror("pipe");
    
        return 1;
    }

    // pipefd[0] 用于读,pipefd[1] 用于写
    return 0;
}

        有名管道:使用mkfifo()函数创建。创建完成后,进程可以像操作普通文件使用open()、read()、write()、close()等函数来操作有名管道。

int main() 
{
    const char *fifo_name = "myfifo";
    if (mkfifo(fifo_name, 0666) == -1) 
    {
        perror("mkfifo");
    
        return 1;
    }
    
    // 打开、读写和关闭有名管道
    int fd = open(fifo_name, O_WRONLY);
    if (fd == -1) 
    {
        perror("open");
    
        return 1;
    }
  
    // 写数据到管道
    close(fd);

    return 0;
}

3.生命周期

        无名管道:生命周期和创建它的进程相关联。当创建它的进程结束,无名管道也就消失了。

        有名管道:在文件系统中有实体存在,它的生命周期和文件系统相关。即便创建它的进程结束,只要不手动删除,有名管道就会一直存在。

4.持久性

        无名管道:是临时性的,进程退出后就会自动销毁。

        有名管道:具有持久性,只要不删除对应的文件,它就能一直存在,后续进程还能继续使用。

5.数据传输方向

        无名管道:半双工,数据只能在一个方向流动,但可以创建两个无名管道来实现全双工。

        有名管道:既可以半双工,也可以全双工。

八、linux中什么是信号,你用过哪些信号?

        linux 系统中,信号是一种软件中断,用于进程间的异步通信。它可以在特定事件发生时通知进程,进程收到信号后会依据预设规则做出响应。

信号的特点

        异步性:信号的发送无需等待进程响应。

        来源多样:既可以由系统事件(按键中断、定时器中断)引发,也能通过系统调用(kill命令)产生。

        预定义行为:每个信号都有默认动作,比如终止进程、忽略信号、暂停进程等。

常见信号

1.SIGINT(2)

        产生方式:按下 Ctrl  +  C  时产生。

        默认行为:终止进程。

2.SIGTERM(15)

        产生方式:通过 kill 命令默认发生该信号

        默认行为:终止进程。

3.SIGKILL(19)

        产生方式:使用 kill  -  9 命令发送。

        默认行为:强制终止进程。

        实际应用:用于终止那些无法通过 SIGTERM 终止的进程。

4.SIGSTOP(19)

        产生方式:使用 kill - stop 命令发送。

        默认行为:暂停进程。

        实际应用:可临时挂起进程。

5.SIGCONT(18)

        产生方式:使用 kill - cont 命令发送。

        默认行为:继续执行已暂停的进程。

        实际应用:与 SIGSTOP 配合使用,可以控制进程的暂停和继续。

6.SIGSEGV(11)

        产生方式:当进程发送段错误时产生。

        默认行为:终止进程并生成 core 文件。

        实际应用:调试内存访问问题。

如何捕获 SIGINT 信号:

void sigint_handler(int signum) 
{
    printf("接收到 SIGINT 信号,进程不会终止!\n");

    // 可以在这里添加清理代码
}

int main() 
{
    // 设置信号处理函数
    signal(SIGINT, sigint_handler);

    printf("运行中,按 Ctrl+C 测试信号处理...\n");

    while (1) 
    {
        sleep(1);
    }

    return 0;
}

九、共享内存是什么?它和管道的区别?

        共享内存和管道都属于进程间通信(IPC)的重要方式。

共享内存

        共享内存是在多个进程之间共享同一块物理内存区域的通信机制。这意味着不同进程能够直接对同一块内存进行读写操作,无需借助数据的复制,因此它的通信效率在所有 IPC 方式中是最高的。

int main() 
{
    // 创建共享内存段
    // IPC_PRIVATE 表示创建一个私有的共享内存段
    // 1024 是共享内存的大小(字节)
    // 0666 是权限标志,表示读写权限
    // IPC_CREAT 表示如果不存在则创建
    int shmid = shmget(IPC_PRIVATE, 1024, 0666 | IPC_CREAT);
    if (shmid == -1) 
    {
        perror("shmget failed");  // 输出错误信息

        exit(EXIT_FAILURE);       // 异常退出
    }

    // 将共享内存段映射到当前进程的地址空间
    // shmat() 返回指向共享内存的指针
    // NULL 表示让系统自动选择映射地址
    // 0 表示默认映射标志
    char *data = (char *)shmat(shmid, NULL, 0);
    if (data == (char *)-1) 
    {
        perror("shmat failed");

        exit(EXIT_FAILURE);
    }

    // 向共享内存写入数据,并使用共享内存
    sprintf(data, "Hello from shared memory!");
    printf("Data written to shared memory: %s\n", data);


    // 解除共享内存映射
    // 这一步不会删除共享内存段,只是断开当前进程与它的连接
    if (shmdt(data) == -1) 
    {
        perror("shmdt failed");

        exit(EXIT_FAILURE);
    }
    printf("Shared memory unmapped.\n");


    // 5. 删除共享内存段(标记为待删除)
    if (shmctl(shmid, IPC_RMID, NULL) == -1) 
    {
        perror("shmctl(IPC_RMID) failed");

        exit(EXIT_FAILURE);
    }
    printf("Shared memory marked for deletion.\n");

    // 此时共享内存段不会立即删除,
    // 直到所有进程都解除映射(引用计数为0)

    return 0;
}

关键特性

        高效性:全双工,双向读写。由于数据无需在进程间复制,所以能显著提升通信效率。

        同步问题:多个进程同时访问共享内存时,需要借助信号量、互斥锁等同步机制来保证数据的一致性。

        生命周期:共享内存的生命周期独立于进程,即使创建它的进程终止,共享内存依然存在,需要手动将其销毁。

管道

        半双工,数据只能沿着一个方向流动。可分为匿名管道(pipe)和命名管道(mkfifo)

int main() 
{
    int fd[2];       // 文件描述符数组,fd[0] 为读端,fd[1] 为写端
    pid_t pid;       // 进程 ID

    // 创建匿名管道
    // 成功时返回 0,并设置 fd[0] 和 fd[1]
    // 失败时返回 -1
    if (pipe(fd) == -1) 
    {
        perror("pipe failed");
        exit(EXIT_FAILURE);
    }

    // 创建子进程
    // 父进程中,fork() 返回子进程的 PID
    // 子进程中,fork() 返回 0
    // 失败时返回 -1
    pid = fork();
    if (pid == -1) 
    {
        perror("fork failed");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) 
    {
        // 子进程:向管道写入数据
        close(fd[0]);  // 关闭不需要的读端
        write(fd[1], "Hello from pipe!", 15);  // 写入数据
        close(fd[1]);  // 写完后关闭写端
    } 
    else 
    {
        // 父进程:从管道读取数据
        close(fd[1]);  // 关闭不需要的写端
        char buffer[100];
        read(fd[0], buffer, sizeof(buffer));  // 读取数据
        printf("Received: %s\n", buffer);
        close(fd[0]);  // 读完后关闭读端
    }

    return 0;
}

关键特性:

        半双工:数据只能从写端流向读端。

        内核缓冲:管道的数据存放在内核缓冲区中,其大小是有限制的(通常64kb)。

        生命周期:匿名管道会随着进程的结束而自动销毁,命名管道则会持续存在,直到被手动删除。

十、说出至少两种linux中进程调度算法?

1.轮转调度

        基础的调度算法,它会为每个进程分配一个固定时长的时间片,进程在这个时间片内运行。一旦时间片用完,该进程就会被暂停,调度器接着选择下一个处于就绪状态的进程运行。这就保证了所有进程能公平地获得 CPU 时间。

/* 轮转调度算法示意 */
struct process {
    int pid;             // 进程ID,唯一标识每个进程
    int arrival_time;    // 进程到达就绪队列的时间点
    int burst_time;      // 进程需要执行的总CPU时间
    int remaining_time;  // 进程尚未执行完的剩余CPU时间
};

/**
 * 实现基于时间片的轮转调度算法
 * @param processes 进程数组
 * @param n 进程数量
 * @param time_quantum 每个进程每次调度允许执行的最大时间单位
 */
void round_robin(struct process processes[], int n, int time_quantum) 
{
    int current_time = 0;      // 当前系统时间
    int completed = 0;         // 已完成执行的进程数量
    int queue[n];              // 就绪队列,存储待执行的进程索引
    int front = 0, rear = -1;  // 队列的头尾指针,初始队列为空
    
    // 主调度循环:直到所有进程执行完毕
    while (completed != n) 
    {
        // 阶段1:扫描所有进程,将已到达且未完成的进程加入就绪队列
        for (int i = 0; i < n; i++) 
        {
            if (processes[i].arrival_time <= current_time && 
                processes[i].remaining_time > 0) 
            {
                // 检查进程是否已在队列中(避免重复添加)
                int in_queue = 0;
                for (int j = front; j <= rear; j++) 
                {
                    if (queue[j] == i) 
                    {
                        in_queue = 1;
                        break;
                    }
                }
                // 若不在队列中,则加入队尾
                if (!in_queue) 
                {    
                    queue[++rear] = i;
                }
            }
        }
        
        // 阶段2:处理就绪队列中的进程
        if (front <= rear) 
        {
            // 取出队列头部的进程
            int current_process = queue[front++];
            
            // 计算本次允许执行的时间:取时间片和剩余时间的最小值
            int execution_time = (processes[current_process].remaining_time < time_quantum) ? processes[current_process].remaining_time : time_quantum;
            
            // 执行进程并更新时间
            processes[current_process].remaining_time -= execution_time;
            current_time += execution_time;
            
            // 判断进程是否完成
            if (processes[current_process].remaining_time == 0) 
            {
                completed++;  // 完成进程计数加1
            } 
            else 
            {
                // 未完成则重新加入队列尾部,等待下一轮调度
                queue[++rear] = current_process;
            }
        } 
        else 
        {
            // 就绪队列为空,系统时间推进(无进程可运行)
            current_time++;
        }
    }
}

2.完全公平调度

        完全公平调度是 Linux 内核 2.6.23 版本之后采用的默认调度算法,主要用于普通进程。它借助红黑树来维护处于就绪状态的进程,按照虚拟运行时间(vruntime)来挑选下一个要运行的进程。该算法的核心目标是确保每个进程都能公平地使用 CPU 资源。

十一、软件编程中,现有一个队列结构,一边写入队列,一边读队列,有哪些注意事项。如果是多任务中,需要注意什么?

1.单线程环境的注意事项

        1.边界检查

                写入时需检查队列是否为满,避免溢出。

                读取时需检查队列是否为空,避免下溢。

        2.状态同步

                读写操作需维护一致的队列状态(队头、队尾指针)。

        3.内存管理

                动态分配的队列空间需确保正确释放,避免内存泄漏。

2.多任务环境的注意事项

        多任务环境下,多个线程 / 进程可能同时访问队列。主要是线程安全同步问题

        1.互斥锁

                使用互斥锁包含队列的读写操作,确保同一时间只有一个线程访问队列。

        2.条件变量

                结合互斥锁实现阻塞等待。

                队列为空时,读线程等待。

                队列满时,写线程等待。

/**
 * 线程安全的循环队列结构
 * 使用环形缓冲区实现FIFO队列,并通过互斥锁和条件变量保证线程安全
 */
typedef struct {
    int *data;       // 动态分配的数组,存储队列元素
    int head;        // 队头索引:指向队列第一个元素
    int tail;        // 队尾索引:指向下一个可用位置
    int size;        // 队列最大容量
    int count;       // 当前元素数量
    pthread_mutex_t lock;       // 保护队列操作的互斥锁
    pthread_cond_t not_full;    // 队列非满条件变量(写等待)
    pthread_cond_t not_empty;   // 队列非空条件变量(读等待)
} Queue;

/**
 * 初始化队列
 * @param size 队列的最大容量
 * @return 初始化后的队列指针
 */
Queue* init_queue(int size) 
{
    Queue *q = malloc(sizeof(Queue));  // 分配队列结构内存
    q->data = malloc(sizeof(int) * size);  // 分配数据数组内存
    q->head = 0;          // 初始化队头索引
    q->tail = 0;          // 初始化队尾索引
    q->size = size;       // 设置队列容量
    q->count = 0;         // 初始元素数量为0
    
    // 初始化同步原语
    pthread_mutex_init(&q->lock, NULL);
    pthread_cond_init(&q->not_full, NULL);
    pthread_cond_init(&q->not_empty, NULL);
    
    return q;
}

/**
 * 销毁队列
 * @param q 需要销毁的队列指针
 */
void destroy_queue(Queue *q) 
{
    // 销毁同步原语
    pthread_mutex_destroy(&q->lock);
    pthread_cond_destroy(&q->not_full);
    pthread_cond_destroy(&q->not_empty);
    
    // 释放动态分配的内存
    free(q->data);
    free(q);
}

/**
 * 入队操作(生产者)
 * @param q 队列指针
 * @param value 需要入队的元素
 */
void enqueue(Queue *q, int value) 
{
    pthread_mutex_lock(&q->lock);  // 加锁保护队列操作
    
    // 等待队列非满:避免生产者覆盖未消费数据
    while (q->count == q->size) 
    {
        pthread_cond_wait(&q->not_full, &q->lock);
        // pthread_cond_wait自动释放锁,并在被唤醒后重新加锁
    }
    
    // 执行入队操作:将元素放入队尾
    q->data[q->tail] = value;
    q->tail = (q->tail + 1) % q->size;  // 循环队列:队尾指针递增并取模
    q->count++;  // 更新元素数量
    
    // 通知可能等待的消费者:队列非空
    pthread_cond_signal(&q->not_empty);
    pthread_mutex_unlock(&q->lock);  // 解锁
}

/**
 * 出队操作(消费者)
 * @param q 队列指针
 * @return 队头元素的值
 */
int dequeue(Queue *q) 
{
    pthread_mutex_lock(&q->lock);  // 加锁保护队列操作
    
    // 等待队列非空:避免消费者读取无效数据
    while (q->count == 0) 
    {
        pthread_cond_wait(&q->not_empty, &q->lock);
    }
    
    // 执行出队操作:从队头取出元素
    int value = q->data[q->head];
    q->head = (q->head + 1) % q->size;  // 队头指针递增并取模
    q->count--;  // 更新元素数量
    
    // 通知可能等待的生产者:队列非满
    pthread_cond_signal(&q->not_full);
    pthread_mutex_unlock(&q->lock);  // 解锁
    
    return value;
}

十二、跨线程在使用全局变量时,需要注意什么?

1.线程安全问题

        多个线程同时读写同一个全局变量时,要是没有合适的同步机制,会导致竟态条件。

int a = 0;

void* increment(void* arg) 
{
    for (int i = 0; i < 100000; i++) 
    {
        a++;  // 非原子操作,存在竞态条件
    }
    return NULL;
}

int main() 
{
    pthread_t threads[2];

    pthread_create(&threads[0], NULL, increment, NULL);
    pthread_create(&threads[1], NULL, increment, NULL);

    pthread_join(threads[0], NULL);
    pthread_join(threads[1], NULL);

    printf("a = %d\n", a);  // 结果可能不是200000

    return 0;
}

2.同步机制的运用

        互斥锁:使用 pthread_mutex_t 来保证同一时间只有一个线程能够访问全局变量。

        读写锁:在读多写少的场景,使用读写锁能提高并发性能。

3.内存可见性问题

        线程对全局变量的修改只存在于本地 CPU 缓存中,其他线程无法马上看到。

        可以使用 volatile 关键字,但 volatile 不能替代同步机制,他主要用于告诉编译器不要对变量进行优化。

4.避免死锁

        用锁来保护全局变量时,要注意加锁的顺序,防止出现循环等待,从而导致死锁。

5.互斥锁示例:

// 全局变量,两个线程会同时对它进行递增操作
int a = 0;

// 互斥锁,用于保护对共享资源的访问
pthread_mutex_t mutex;

// 线程执行的函数,负责对 a 进行100000次递增
void* increment(void* arg) 
{
    for (int i = 0; i < 100000; i++) 
    {
        // 加锁,确保同一时间只有一个线程可以执行下面的代码块
        pthread_mutex_lock(&mutex);
     
        a++;  // 现在是线程安全的,因为有互斥锁保护

        // 解锁,允许其他线程获取锁并访问共享资源
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main() 
{
    // 创建线程ID数组,用于存储两个线程的ID
    pthread_t threads[2];

    // 初始化互斥锁
    pthread_mutex_init(&mutex, NULL);
    
    // 创建两个线程,都执行increment函数
    pthread_create(&threads[0], NULL, increment, NULL);
    pthread_create(&threads[1], NULL, increment, NULL);
    
    // 等待两个线程执行完毕
    pthread_join(threads[0], NULL);
    pthread_join(threads[1], NULL);
    
    // 销毁互斥锁,释放资源
    pthread_mutex_destroy(&mutex);

    // 输出最终结果,预期是200000(每个线程递增100000次)
    printf("a = %d\n", a);

    return 0;
}

     

十三、你知道哪些锁机制?互斥锁,读写锁,自旋锁的区别?

1.互斥锁

        互斥锁是一种用于保护共享资源的方法,确保同一时间只有一个线程可以访问该资源。当一个线程获取锁时,其他线程若想获取该锁,就会被阻塞,直到锁被释放。

2.读写锁

        读写锁允许多个线程同时进行读操作,但在写操作时,会排斥其他所有的读和写操作。这样的目的是在多线程环境中,当读操作远远多于写操作时,能够提高并发性能。

pthread_rwlock_t rwlock;

void* reader(void* arg) 
{
    // 获取读锁
    pthread_rwlock_rdlock(&rwlock);
    
    // 读操作代码
    // ...
    
    // 释放读锁
    pthread_rwlock_unlock(&rwlock);

    return NULL;
}

void* writer(void* arg) 
{
    // 获取写锁
    pthread_rwlock_wrlock(&rwlock);
    
    // 写操作代码
    // ...
    
    // 释放写锁
    pthread_rwlock_unlock(&rwlock);

    return NULL;
}

int main() 
{
    // 初始化读写锁
    pthread_rwlock_init(&rwlock, NULL);
    
    // 创建读线程和写线程...
    
    // 销毁读写锁
    pthread_rwlock_destroy(&rwlock);

    return 0;
}

3.自旋锁

        自旋锁是一种忙等待锁。当线程尝试获取锁但失败时,不会进入睡眠状态,而是持续循环检查锁的状态,直到锁被释放。这种锁比较适用于锁持有时间短、线程不希望在等待锁时进行上下文切换的场景。

pthread_spinlock_t spinlock;

void* thread_function(void* arg) 
{
    // 获取自旋锁
    pthread_spin_lock(&spinlock);
    
    // 临界区代码
    // ...
    
    // 释放自旋锁
    pthread_spin_unlock(&spinlock);

    return NULL;
}

int main() 
{
    // 初始化自旋锁
    pthread_spin_init(&spinlock, PTHREAD_PROCESS_PRIVATE);
    
    // 创建线程...
    
    // 销毁自旋锁
    pthread_spin_destroy(&spinlock);

    return 0;
}

4.各自的区别

互斥锁:

        适用于各种场景

        线程会被阻塞,进入睡眠状态。

        会发生上下文切换。

        在锁竞争激烈时,性能开销比较大。

        资源消耗较少。

读写锁:

        适用于读多写少的场景。

        线程会被阻塞,进入睡眠状态。

        会发生上下文切换。

        在读写分离的时候,性能较好。

        资源消耗一般。

自旋锁:

        适用于锁持有时间短的场景

        线程会忙等待,持续循环检查。

        不会发生上下文切换。

        在锁持有时间短时,性能较好。

        资源消耗较多,因为需要持续占用 CPU。


网站公告

今日签到

点亮在社区的每一天
去签到