【操作系统】并发程序设计

Alex_Shen
2022-03-31 / 0 评论 / 0 点赞 / 150 阅读 / 7,921 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2022-04-05,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

实验目的

加深对进程的创建、运行、撤销过程的直观认识;

掌握通过操作系统的用户接口(命令行和系统函数)控制进程状态的方法;

了解多进程在多核处理机上的并发执行过程;

实验环境

Centos 7.6

实验内容:

可以使用Linux或其它Unix类操作系统;

学习该操作系统提供的命令行启动、撤销进程的方法;

学习该操作系统提供的系统调用接口(借助于库函数的形式间接调用)启动和撤销进程;

利用该操作系统提供的工具观测这些程序的并发执行过程以及状态转换过程。

实验步骤:

1. 预备部分:

1) 学习top、ps、pstree和kill等命令的使用。能通过top和ps j命令查看进程号、父进程号、可执行文件名(命令)、运行状态信息,能通过pstree查看系统进程树;能通过kill命令杀死制定pid的进程。

首先依次对于上述提出的指令进行测试

Top指令

img

Ps指令

img

img

Pstree指令

img

Kill指令

首先随意编写了一个c语言程序,使用getchar()函数进行阻塞,并在后台运行

img

使用kill -9 pid杀死进程

img

2) 学习/proc/PID/maps的输出信息。

再次运行test函数

img

3) 了解/porc/PID/status中各项内容的。

img

2. 操作部分(1/2/3/5/每点完成度各计20%):

1) 使用fork()创建子进程,形成以下父子关系:

img

要求:并通过/proc文件系统,检查进程的pid和ppid证明你成功创建相应的父子关系,并用pstree验证其关系。

fork() 是Unix系统创建子进程的唯一方法,其他包或模块的底层都调fork。fork作用是复制克隆一个新进程(子进程),继续同时向下执行。

fork函数的特点是fork被调用一次,返回两次,一次在父进程中返回子进程PID,一次在子进程中返回0。fork失败返回负数,发生在PID个数达上限或内存不足时。

A. 情形1

循环10此,每一次如果是父进程就fork一次,即可完成10个子进程的创建

代码如下所示:

#include <stdio.h>
#include <unistd.h>

int main() {
    int i = 0;
    for (i = 0; i < 10; i++) {
        __pid_t pid = fork();
        if (pid < 0)  // 错误检测
            printf("error\n");
        else if (pid == 0)  // 子进程
            break;
        else if (pid > 0)  // 父进程
            continue;
    }
    pause();  // 阻塞
    return 0;
}

编译运行此代码,并使用ps指令查看进程。可以看到一共创建了11个进程。

img

使用cat /proc/pid/status | head -n 7查看进程信息。其中Pid为当前进程的pid,ppid的父进程的pid。

首先查看父进程的进程信息,可以看到其父进程pid=26719,为bash。

img

然后依次查看剩余10个子进程的进程信息

imgimg

img

img

可以看到所有子进程的父进程都是27039。符合题目要求

同样也可以使用pstree -p pid进行验证

img

B. 情形2

循环10此,每一次如果是子进程就fork一次,即可完成10个子进程的创建

代码如下所示:

#include <stdio.h>
#include <unistd.h>

int main() {
    int i = 0;
    for (i = 0; i < 10; i++) {
        __pid_t pid = fork();
        if (pid < 0)  // 错误检测
            printf("error\n");
        else if (pid == 0)  // 子进程
            continue;
        else if (pid > 0)  // 父进程
            break;
    }
    pause();  // 阻塞
    return 0;
} 

编译运行此代码,并使用ps指令查看进程。可以看到一共创建了11个进程。

img

使用pstree -p pid查看进程间的关系,如题目所要求

img

C. 情形3

循环三层,一个父进程创建2个子进程才能结束,然后由子进程继续创建

代码如下:

#include <stdio.h>
#include <unistd.h>

int main() {
    int i = 0, j = 0;
    for (i = 0; i < 3; i++) {
        __pid_t pid = fork();
        if (pid < 0)  // 错误检测
            printf("error\n");
        else if (pid > 0) {  // 父进程
            __pid_t ppid = fork();
            if (ppid > 0)
                break;
        }
    }
    pause();  // 阻塞
    return 0;
}

编译运行此代码,并使用ps指令查看进程。可以看到一共创建了15个进程。

img

使用pstree -p pid查看进程间的关系,如题目所要求

img

2) 编写代码实现孤儿进程,用pstree查看孤儿进程如何从原父进程位置转变为init进程所收养的过程;编写代码创建僵尸进程,用ps j查看其运行状态。

要求:用Linux系统提供的信息,展示并记录上述进程状态及变化

A. 孤儿进程

孤儿进程指的是在其父进程执行完成或被终止后仍继续运行的一类进程。

创建如下代码,如果是子进程,则使用pause使其始终运行该进程。

#include <stdio.h>
#include <unistd.h>

int main() {
    __pid_t pid = fork();
    if (pid == 0)  // 子进程
        pause();    // 阻塞子进程
    if (pid > 0) {  // 父进程
    }
    return 0;
}

编译并运行此代码,并使用ps指令查看进程。可以看到程序运行结束后仍然有一个子进程存活。

img

使用cat /proc/pid/status 查看进程信息,可以看到其父进程为pid = 1,说明该进程从原父进程位置被init进程所收养。

img

使用pstree -p进行验证,成为了孤儿进程

img

img

B. 僵尸进程

僵尸进程是指完成执行(通过exit系统调用,或运行时发生致命错误或收到终止信号所致),但在操作系统的进程表中仍然存在其进程控制块,处于"终止状态"的进程。

创建如下代码,子进程先结束,那么子进程结束之后就会编程僵尸进程。

#include <stdio.h>
#include <unistd.h>

int main() {
    __pid_t pid = fork();

    if (pid == 0) {
    }             // 子进程
    if (pid > 0)  // 父进程
        pause();  // 阻塞
    return 0;
}

子进程结束后,因为父进程没有做wait处理,于是子进程变为僵尸进程,状态变为了Z。

img

3) 创建多个线程,在各个线程中打印出堆栈变量的地址。

要求:比较各线程的/proc/PID/maps是否相同。检查主线程的堆栈和其他线程堆栈位置在/proc/PID/maps所对应的位置差异。

linux中创建线程需要调用pthread_create函数。

第一个参数为指向线程标识符的指针。

第二个参数用来设置线程属性。

第三个参数是线程运行函数的地址。

最后一个参数是运行函数的参数。

需要注意的是在编译时注意加上-l pthread参数,以调用静态链接库。因为pthread并非Linux系统的默认库。

(参考https://blog.csdn.net/u011006622/article/details/73565135)

具体代码如下所示:

#include <malloc.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#define __NR_gettid 186
void func(void* p) {
    pthread_attr_t attr;
    void* stack_addr;
    size_t stack_size;

    pthread_getattr_np(pthread_self(), &attr);
    pthread_attr_getstack(&attr, &stack_addr, &stack_size);

    pid_t pid = getpid();
    long int tid = (long int)syscall(__NR_gettid);

    printf("pid: %lu, tid: %lu, stack address: %p, stack size: %lu\n", pid, tid,
           stack_addr, stack_size);

    pause();
}

int main() {
    for (int i = 0; i < 3; i++) {
        pthread_t tid;
        pthread_create(&tid, NULL, (void*)&func, NULL);
    }
    printf("\n");
    pause();

    return 0;
}

编译并运行程序,可以看到每个线程的id号与地址等信息

img

img

查看每个线程的/proc/pid/maps信息,可以看到每个线程的信息都相同。

img

img

img

img

堆栈大小则输出了 相同的 8M 的大小,而堆栈地址则是每个子线程自己的堆栈地址。通过观察 /proc/PID/maps 中的数据也可以查看到,子线程的堆栈是在主线程堆区申请的“虚拟堆栈”。三个子线程具有自己独立的栈空间内存,并且这些内存是由 pthread 库自动申请的所以位于 heap 区,而主线程的栈空间则不和他们一起存放。

4) 分别创建相同数量的进程和线程

要求:比较进程控制块开销的差异、内存vma描述符开销的差异,并简要解释原因。

首先编写两个程序用于创建100个线程与100个进程

进程创建:

利用循环语句创建100个进程,并且令每一个进程都睡眠10秒后结束。然后在主程序完成后,打印出提示语句。具体代码实现如下:

#include <stdio.h>
#include <unistd.h>
int main(int argc, char** argv) {
    int i;
    for (i = 0; i < 100; i++) {
        int pid = fork();
        if (pid == -1) {
            printf("error!\n");
        } else if (pid == 0) {
            // printf("This is the child process!\n");
            sleep(10);
            return 0;
        } else {
            // printf("This is the parent process! child process id = %d\n",pid);
        }
    }
    printf("The main function for process is over.\n");

    return 0;
}

线程创建:

利用循环语句创建100个线程,并且令每一个线程都睡眠10秒后结束。然后在主程序完成后,打印出提示语句。具体代码实现如下:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
void thread(void) {
    // printf("This is a pthread.\n");
    sleep(10);
}
int main(void) {
    pthread_t id[100];
    int i, ret;
    for (i = 0; i < 100; i++) {
        ret = pthread_create(&id[i], NULL, (void *)thread, NULL);
        if (ret != 0) {
            printf("Create pthread error!\n");
            exit(1);
        }
    }
    // printf("This is the main process.\n");
    for (i = 0; i < 100; i++) {
        pthread_join(id[i], NULL);
    }

    printf("The main function for thread is over.\n");
    return (0);
}

第一个任务是比较进程控制块开销的差异:

由于 Linux 中的进程动态的创建和撤销,因此在数量上并不十分准确,但是大体上能看出变化的趋势。/proc/slabinfo 中统计了各种内核数据对象的数量,其中也包括进程控制块的数量。

在./process运行前、运行中和结束后各执运行一次 cat /proc/slabinfo | grep task_struct,如下输出:

运行前:task_struct数量509个

img

运行中:task_struct数量609个

img

结束后:task_struct数量523个

img

可以看出,在./process运行时,比运行前后都大约多了100个task_struct的数据对象(由于有进程动态创建与撤销,因此数据有扰动)。

在./thread运行前、运行中和结束后各执运行一次 cat /proc/slabinfo | grep task_struct,如下输出:

运行前:task_struct数量436个

img

运行中:task_struct数量539个

img

结束后:task_struct数量435个

img

可以看出,在./thread运行时,比运行前后都大约多了100个task_struct的数据对象(由于有进程动态创建与撤销,因此数据有扰动)。

综上所述,线程作为调度执行单位,进程控制块 PCB(task_struct)还是需要的,这是最小资源的一部分。因此这方面资源开销对进程和线程都是一样的。

第二个任务是比较内存vma描述符开销的差异:

① 使用cat /proc/slabinfo |grep mm_struct 命令来检查process/thread运行前、运行

中和结束后的mm_struct数量。

在./process运行前、运行中和结束后各执运行一次 cat /proc/slabinfo | grep mm_struct,如下输出:

运行前:mm_struct数量114个

img

运行前:mm_struct数量200个

img

运行后:mm_struct数量114个

img

在./thread运行前、运行中和结束后各执运行一次 cat /proc/slabinfo | grep mm_struct,如下输出:

运行前:mm_struct数量114个

img

运行前:mm_struct数量114个

img

运行后:mm_struct数量114个

img

属于同一进程的线程间共享进程的内存空间,因此共用一个内存描述符mm_struct。因此,创建 100 个进程也将需要创建 100 个 mm_struct,但是在进程的主线程存在的前提下创建 100 个线程则不需要新创建任何 mm_struct。

② 使用cat /proc/slabinfo |grep vm_area_struct命令来检查process/thread运行前、运行中和结束后的vm_area_struct数量。

在./process运行前、运行中和结束后各执运行一次 cat /proc/slabinfo | grep task_struct,如下输出:

运行前:vm_area_struct数量9829个

img

运行中:vm_area_struct数量11726个

img

结束后:vm_area_struct数量9824个

img

可以看出,在./process运行时,比运行前后都大约多了1900个vm_area_struct的数据对象。

在./thread运行前、运行中和结束后各执运行一次 cat /proc/slabinfo | grep task_struct,如下输出:

运行前:vm_area_struct数量9817个

img

运行中:vm_area_struct数量10028个

img

结束后:vm_area_struct数量10338个

img

可以看出,在./process运行时,比运行前后都大约多了200个vm_area_struct的数据对象。

综上所述,由于每个进程需要一个独立的用户台堆栈,这些堆栈需要一个独立的vm_are_struct结构体来描述,因此将会增加100个vm_area_struct用于描述线程用户态栈。但是 100个进程的话就更多,每个简单的进程至少需要大约 19 个 vm_area_struct 结构体,100 个进程需要增加大约1900个 vm_area_struct;然而每个简单的线程只需要大约2个vm_area_struct结构体,100个线程只需要增加大约200个 vm_area_struct。从我们的实验可以看出,线程的开销确实比进程要小。

5) 尝试自行设计一个C语言小程序,完成最基本的shell角色

要求:给出命令行提示符、能够逐次接受命令;对于命令分成三种,内部命令(实现help命令给出用法、exit命令退出shell)、外部命令(即磁盘上的可执行文件)以及无效命令(不是上述两种命令)。

编写自己的shell程序,根据获取到的字符串进行判断。

如果是help指令,会打印出相对应的帮助信息;如果是exit指令,会直接退出shell程序,如果是exec指令,会创建一个新的进程,并调用c语言中的execlp函数并执行传入的指令。如果是错误指令,会提示错误信息。

其中execlp指令会从PATH环境变量所指得目录中查找符合参数file的文件名,找到后便执行该文件,然后将第二个以后的参数当作该文件的argv[0]、argv[1]…,最后一个参数必须用空指针(NULL)结束。

代码:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <wait.h>

int main() {
    printf("This is a new shell program!\n");
    char cmd[128];
    while (1) {
        printf("shenchenyu's shell> ");
        scanf("%s", cmd);
        if (strcmp(cmd, "exit") == 0)
            break;
        else if (strcmp(cmd, "help") == 0) {
            printf("----------------\n");
            printf("内部命令: \n");
            printf("help  : 帮助信息\n");
            printf("exit  : 退出\n----------------\n外部命令: \n");
            printf("exec + cmd\n----------------\n");
        } else if (strcmp(cmd, "exec") == 0) {
            char out_cmd[128];
            scanf("%s", out_cmd);
            pid_t pid = fork();
            if (pid > 0) {
                wait(NULL);
                continue;
            }
            if (pid == 0) {
                int s = execlp(out_cmd, "", NULL);
                if (s == -1) printf("运行指令 %s 失败\n", out_cmd);
                return 0;
            }
        } else {
            printf("无效命令\n");
        }
    }
    printf("退出shell \n");

    return 0;
}

执行结果:

img

0

评论区