深大操作系统实验一:并发程序设计

前言

第一个实验就把我干碎了!好大的压力呀。操作系统像一座大山,压在我的狗脑子上,一点气都喘不过来。

在无数次的摆烂,抄代码,百度之后,我最终还是挺过来了,并且踉跄地把实验写完。我踩了无数个坑,分享一下我自己的实验思路。

你需要一台 linux,并且安装了 ps,pstree 等命令,他们属于 psmisc 包。

预备部分

学习 top、ps、pstree 和 kill 等命令的使用。

能通过 top 和 ps j 命令查看进程号、父进程号、可执行文件名(命令)、运行状态信息,能通过 pstree 查看系统进程树;

能通过 kill 命令杀死制定 pid 的进程。

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

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


这个直接一路打命令就完事了。。。直接贴过程:

使用 top 命令查看各进程的系统资源占用情况:

在这里插入图片描述

再来使用 ps 命令,查看当前进行的进程信息快照:

在这里插入图片描述

我们编写一段简单的 c 程序。产生两个进程,并且通过 getchar() 进行阻塞。将该文件保存为 1.c,文件的内容如下:

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

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

    if(pid < 0) printf("error!");   // error ckeck
    if(pid == 0) getchar();         // child progress
    if(pid > 0) getchar();          // parent progress

    return 0;
}

然后将程序上传到云服务器,这里通过 WinSCP 软件进行:

在这里插入图片描述

注:
如果你是虚拟机,那直接传磁盘就好了
这里我是租的云服务器

然后使用命令 gcc 1.c -o hello 进行编译:
在这里插入图片描述

随后执行该文件,通过命令 ./hello & 执行,此处 & 表示在后台执行即可。这里比对前后进程信息,可以发现我们产生了两个进程:

在这里插入图片描述

然后使用 pstree 命令查看进程之间的关系,我们选择 7444 号进程。命令为:pstree -p 7444,可以看到结果如下:

在这里插入图片描述

7444 号进程产生了 7445 号子进程!也可以通过 pstree -p 来查看完整的系统进程树并且确定 hello 的位置:

在这里插入图片描述
最后我们 kill 掉父进程 7444,使用 kill -Kill 7444,结果如下:

在这里插入图片描述
将父进程 kill 掉,子进程也随着消失。再次启动 hello,此时 hello 换了个号 8935 了:

在这里插入图片描述
查看 8935 号线程的内存映射信息,通过命令 cat /proc/线程id/maps 即可:

在这里插入图片描述
比如 hello 的栈空间,虚拟变量,虚拟动态共享变量,虚拟系统调用的地址。再通过命令 cat /proc/线程id/status 进行进程状态的查看:

在这里插入图片描述

操作部分

大的要来了。

1.使用 fork 创建进程

使用 fork() 创建子进程,形成以下父子关系:
在这里插入图片描述
要求:并通过 /proc 文件系统,检查进程的 pid 和 ppid 证明你成功创建相应的父子关系,并用 pstree 验证其关系。

1A. 创建 10 个子进程。

循环 10 次,每次都 fork 一下即可,下面是代码:

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

int main()
{
    
    
    for(int i=0; i<10; i++)
    {
    
    
        pid_t pid = fork();
        if(pid < 0) printf("error!");   // error ckeck
        if(pid == 0) break;             // child progress
        if(pid > 0) continue;           // parent progress
    }
    getchar();  // prevent exit

    return 0;
}

将上述代码保存为 t1a.c,意为第一题的 a 部分。然后编译并且执行该代码,可以看到产生了 1+10=11 个进程:

在这里插入图片描述
然后依次查看进程 13819~13828 号的 status 信息,检查 PPid 字段以验证其父亲是哪位进程。通过命令:cat …/…/…/proc/138xx/status | head -n 10依次查看:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

可以看到 10 个子进程的父进程都是 13818,也可以通过 pstree 来验证。执行命令:pstree -p 13818 可以发现 10 个子进程:

在这里插入图片描述

1B. 10 层子进程嵌套

只要在 A 题的基础上交换一下父子进程两个 if 即可。如果是父进程我们直接 break,而子进程则继续 continue,继续产生子子进程。

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

int main()
{
    
    
    for(int i=0; i<10; i++)
    {
    
    
        pid_t pid = fork();
        if(pid < 0) printf("error!");   // error ckeck
        if(pid == 0) continue;          // child progress
        if(pid > 0) break;              // parent progress
    }
    pause();  // prevent exit

    return 0;
}

再次运行代码,也是产生了 11 个进程:
在这里插入图片描述
通过 pstree 命令查看进程间的关系:
在这里插入图片描述
可以发现确实是嵌套创建了 10 个进程。

注:
在 1A 题中直接 kill 掉父进程即可杀完全部子进程,因为使用 getchar 作为阻塞,在父进程死后子进程被 init 收养时,因为没有可以输入的控制台,于是直接结束。而 1B 题我这里使用 getchar 并且在后台运行时,子进程总是莫名其妙被 kill 且 ps 只能显示两个进程,(因为使用的远程登陆只有一个控制台,如果用虚拟机的话开多一个控制台,然后前台运行即可避免子进程被奇怪地 kill 掉)但是用 pause 阻塞则不会出现异常,但代价是无法通过杀死父进程来杀死子进程

于是使用命令:

ps -efww|grep t1b |grep -v grep|cut -c 9-15|xargs kill -9

杀除全部名为 t1b 的进程即可。

1C. 树形创建

树形创建也和上面差不多,一个父节点得一次创建 2 个子进程才能结束,然后剩下的交给子进程重复即可。代码如下:

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

int main()
{
    
    
    for(int i=0; i<3; i++)
    {
    
    
        pid_t pid = fork();
        if(pid > 0)             // parent progress
        {
    
    
            pid = fork();
            if(pid > 0) break;  // generate twice
        }        
    }
    pause();  // prevent exit

    return 0;
}

运行代码,产生了 15 个进程,这和题目要求的树型(在数量上)吻合:

在这里插入图片描述

通过 pstree 命令也可以清晰地看到树形结构:

在这里插入图片描述

2.僵尸与孤儿进程

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

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


2A. 孤儿进程

孤儿进程要求父进程结束而子进程未结束。于是编写代码,用 pause 阻塞子进程,而父进程则 sleep 几秒然后结束,这样能够创造一个孤儿进程:

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

int main()
{
    
    
    pid_t pid = fork();
    if(pid == 0) while(1){
    
    };    // child progress       
    if(pid > 0) sleep(5);       // parent progress

    return 0;
}

可以看到父进程在结束之前一直接管子进程,而父进程结束之后,子进程则被 1 号进程收养,并且仍然存活:

在这里插入图片描述

再次启动程序,用查看 pstree 的信息也可以观察子进程被收养的过程。首先看到子进程(29266)原本依赖于父进程(29265),如图:

在这里插入图片描述

在父进程 sleep 结束之后,子进程被 init 进程接管:

在这里插入图片描述

2B. 僵尸进程

父进程不使用 wait 等手段处理子进程,并且子进程先结束,那么子进程结束之后就会变成僵尸进程。代码很简单:

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

int main()
{
    
    
    pid_t pid = fork();
    if(pid > 0) while(1){
    
    }; // parent progress       
    if(pid == 0) sleep(5);  // child progress

    return 0;
}

一开始是正常的。子进程(32132)依附于父进程(32313)

在这里插入图片描述
然后等到子进程结束,因为父进程没有做 wait 等处理,于是子进程变为僵尸,可以看到状态也随着变为 Z 表示 zombie:

在这里插入图片描述
而如果使用 wait 来处理子进程的结束,那么就不会出现僵尸:

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

int main()
{
    
    
    pid_t pid = fork();
    if(pid > 0)             // parent progress
    {
    
    
        wait(NULL);
        while(1){
    
    };  
    }     
    if(pid == 0) sleep(5);  // child progress

    return 0;
}

在这里插入图片描述

3.线程与线程堆栈

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

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


主线程创建 3 个子线程并且阻塞,代码如下:

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

#define __NR_gettid 186
void speak(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*)&speak, NULL);
    }
    printf("\n");
    pause();

    return 0;
}

通过

gcc ./t3.c -o t3 -l pthread 

来链接到 pthread 并且编译代码,然后后台运行

注:
编译的时候带上 -l lpthread 选项
打印地址的时候用 %p 不然地址不对

通过 ps -mp PID -o THREAD,tid,time 查看该进程的所有线程信息,可以看到确实由一个主线程 + 三个子线程组成:

在这里插入图片描述
再观察子线程的输出:

在这里插入图片描述
堆栈大小则输出了 3 个相同的数字,是 8388608,也就是 8 x 1024 x 1024,即 8M 的大小,而堆栈地址则是每个子线程自己的堆栈地址。通过观察 /proc/PID/maps 中的数据也可以查看到,子线程的堆栈是在主线程堆区申请的“虚拟堆栈”:

在这里插入图片描述
其中 heap 区有三段大小相同,并且由 “只读+可读可写” 段组成的内存,是三个子线程的堆栈保护缓冲和堆栈空间,如下图:

在这里插入图片描述
其中堆栈保护缓冲固定为 4kb,位于上半部分。而堆栈的空间位于每个框框的下半部分的那一行。如果不进行配置,那么堆栈大小默认为 8M。从图中也可以看出堆栈的大小确实是 8M,比如线程 21448(对应上图黄色框框)堆栈大小这么计算:

0x7f6240749000 - 0x7f623ff49000 = 0x800000 
                                = 8388608 = 8 x 1024 x 1024 = 8M

而主线程的栈空间则在 [stack] 段,大小为 0x7feca29bd000 – 0x7feca29bc000 = 4kb:

在这里插入图片描述

结论:三个子线程具有自己独立的栈空间内存,并且这些内存是由 pthread 库自动申请的所以位于 heap 区,而主线程的栈空间则不和他们一起存放。

4.进程线程开销比较

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

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


通过百度查阅资料发现,Linux 的线程其实是由进程模拟的,每个线程拥有自己独立的 PCB,也就是 task_struct

而子线程的内存描述符 mm_struct 和 vma 描述符 vm_area_struct 则是一致指向 main 进程的,也就是子线程共享一套地址空间。

以 3 为例:

创建 3 个进程,就需要消耗 3 个 task_struct,3n 个 vm_area_struct
创建 3 个子线程,需要消耗 3 个 task_struct,1n 个 vm_area_struct

(假设 vm_area 链表就有 n 个元素,其中 n 是虚拟内存块的数目)

下面通过代码来证实。创建 3 个进程的代码和上文类似,我们叫做 t4_process.c,代码如下:

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

int main()
{
    
    
    for(int i=0; i<2; i++)
    {
    
    
        pid_t pid = fork();
        if(pid < 0) printf("error!");   // error ckeck
        if(pid == 0) break;             // child progress
        if(pid > 0) continue;           // parent progress
    }
    pause();  // prevent exit

    return 0;
}

创建 3 个线程同理,叫做 t4_thread.c,代码如下:

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

void speak(void* p)
{
    
    
    pause();
}

int main()
{
    
    
    for(int i=0; i<3; i++)
    {
    
    
        pthread_t tid;
        pthread_create(&tid, NULL, (void*)&speak, NULL);
    }
    pause();

    return 0;
}

因为查看 task_struct 和 vma 是 内核态程序 才能完成的事情,一般的用户程序很难实现,于是我编写一个 module 并且插入到 linux 内核中

这个模组接收进程 id 并且打印对应进程的 task_struct 和 vma 的地址,模组的代码 t4.c 内容如下:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/sched.h>
#include <linux/sched/signal.h>
#include <linux/moduleparam.h>

int pid = 114514;
module_param(pid, int, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
MODULE_PARM_DESC(pid, "An integer");

static int hello_init(void)
{
    
    
    struct task_struct* p;
    struct task_struct* t;
    printk(KERN_ALERT "start print proc %d 's info\n", pid);
    
    for_each_process(p)
    {
    
    
        if(pid==p->pid)
        {
    
    
            printk(KERN_ALERT "pid: %i, mm addr: %lx, vma addr %lx: \n", p->pid, p->mm, p->mm->mmap);
            t = p;
            while ((t=next_thread(t))!=p)
                printk(KERN_ALERT "tid: %i, mm addr: %lx, vma addr %lx: \n", t->pid, t->mm, t->mm->mmap);
        }  
    }   
    
    return 0;
}

static void hello_exit(void)
{
    
    
    
}

module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("Dual BSD/GPL");

模组的 Makefile 内容如下:

obj-m :=t4.o
KERNELDIR :=/lib/modules/$(shell uname -r)/build
PWD :=$(shell pwd)

modules:
	$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
modules_install:
	$(MAKE) -C $(KERNELDIR) M=$(PWD) modules_install

clean:
	rm -rf *.o *~ core .depend .*.cmd *.ko.* *.mod.c .tmp_versions

执行 make 命令,生成 .ko 文件,再使用 insmod ./t4.ko pid=xxxx 将模组插入内核,最后通过 dmesg -c 命令查看 printk 输出。

首先我们运行创建 3 个子线程的程序 t4_thread,然后使用内核模块查看其信息:

在这里插入图片描述
pid和tid是由 task_struct 获得的,tid 的值不同,说明子线程拥有不同的 PCB。

而第一行是进程的 mm_struct 和 vm_area_struct 的地址,而后三行是三个子进程的,可以看到他们相等。说明子线程和主进程共享一套地址空间。


再来看 3 个进程的情况:运行 t4_process 创建 3 个进程

在这里插入图片描述
然后反复插入模块,并且打印信息:

在这里插入图片描述

可以看到,三个进程的 mm_struct 和 vm_area_struct 地址均不同,说明进程之间的地址空间相互独立。

总结:等数量的线程与进程,PCB 开销不变,进程消耗的 vma 描述符是线程的 3 倍(假设他们都申请同样大小的虚拟内存,即 vma 链表元素个数一致),因为进程的地址空间相互独立。

5.自定义shell

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

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


思路:内部命令比较简单,直接执行即可。外部命令,通过 fork 创建新进程,如果是子进程就读取用户输入的外部命令,然后 execlp 执行外部命令,并且返回结果。

内部命令一共三条,分别是 help 帮助,exit 退出,和 story 讲故事。而外部命令需要在前面添加 exec,比如 ls 命令,就需要 exec ls 才行。

代码如下:

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

int main()
{
    
    
    printf("welcome to my shell!\n");
    char cmd[1024];
    while(1)
    {
    
    
        printf("myshell> ");
        scanf("%s", cmd);
        if(strcmp(cmd, "exit")==0) break;
        else if(strcmp(cmd, "help")==0)
        {
    
    
            printf("----------------\n内部命令:\n");
            printf("help  : 打印帮助信息\n");
            printf("story : 讲故事\n");
            printf("exit  : 退出\n----------------\n外部命令:\n");
            printf("exec + 命令\n----------------\n");
        }
        else if(strcmp(cmd, "story")==0)
        {
    
    
            printf("闹的挺大,我们县的高中都传疯了。先说一下,我高二,以前是个二刺猿,但现在很少看动漫,最多也就玩个二次元游戏。我有个初中同学,我俩一起入宅的。现在就在我隔壁班。我俩也经常一起吃饭出去玩。而他班上有个傻币二次猿,上课天天看动漫玩游戏买手办,成绩一直是班上倒数第一,还特别喜欢充钱。听我朋友说光明日方舟他就充了几万。而他班上入宅的人很少,他就黏住我朋友,经常在我俩一起起饭的时候凑进来。但我和我朋友一直都挺不喜欢他的,他经常在我们面前吹嘘他又氪金抽到了什么什么,我俩没有之类的话,还喜欢贬低别的动漫,经常不在意我俩的脸色就在贬低我俩喜欢的角色,他这人人品还有问题。他在他自己班上口碑也很差,几乎没什么朋友。而在上个月初,国庆假时,他玩原神充了三万rmb,他还不满足,还有角色没满命,他就去偷钱,被他父亲抓了个现行,然后他俩居然打起来了,最后他拿走五万便回学校去了。说一下,我学校是全日制寄宿式学校,每个星期六放半天假,每个月月底放2-3天假。然后事情来了,他十月月底回家时,发现他父母都不在家,只有他妹在家,他就把他妹强奸了,还威胁他妹不准告诉父母。然后这个月月初回校中午吃饭时他就和我们说他妹肯定是兄控,肯定喜欢他,于是他就和他妹做了,他还讲了细节,当时我和我朋友没相信。对了,他妹刚12。然后前天10点左右的样子,他父母可能知道他强奸他妹了,就跑来学校,当场就打断他一只腿。他却还说着什么要和他父亲拼命。当时很多人录下来了,发到各高中学校群,我们县所有高中都知道了这件事,虽然说现在学校把消息压下来了,我们学校还专门用半天时间告诉我们这件事不要乱传之类的。反正当时闹公安去了,后面发生了什么我也不知道。我现在只想劝你们早日放弃二刺猿,不要变成像他那样的人。\n");
        }
        else if(strcmp(cmd, "exec")==0)
        {
    
    
            char ecmd[1024];
            scanf("%s", ecmd);
            pid_t pid = fork();
            if(pid>0) // parent
            {
    
    
                wait(NULL);
                continue;
            }
            if(pid==0)  // child
            {
    
    
                int s = execlp(ecmd, ecmd, NULL);
                if(s==-1) printf("fail to exec %s\n", ecmd);
                return 0;
            }
        }
        else
        {
    
    
            printf("无效命令\n");
        }
    }
    printf("bye\n");

    return 0;
}

演示:
首先是 help 和 story 两个内部命令:

在这里插入图片描述
然后执行外部命令:分别执行 ls, ps, pwd 命令,以及磁盘上面的 hello 可执行文件

在这里插入图片描述
最后是无效命令和退出:

在这里插入图片描述
退出之后回到原本的 linux 控制台,程序结束。

总结

  1. 使用一系列的 Linux 命令操控进程,比如 ps,kill 等命令
  2. 在 Linux 下进程通过 fork 创建分支,通过返回值判断是否为子进程
  3. 在 Linux 下通过 pthread 库创建线程
  4. 线程其实是通过进程模拟的,每个线程都有自己的 PCB,只是线程之间共享一套内存
  5. 线程栈其实在主进程的堆区被申请
  6. 创建线程的开销通常小于创建等数量的进程
  7. 通过 proc/pid/xxx 查看 PCB 信息,也可以通过内核态的程序查看task_struct

猜你喜欢

转载自blog.csdn.net/weixin_44176696/article/details/114884101