操作系统原理的上机实验的报告一共有四个,其他的报告的地址是:
第一次:https://shiraha.cn/2020/class-FoDOS-experiment-1/
第二次:https://shiraha.cn/2020/class-FoDOS-experiment-2/
第三次:https://shiraha.cn/2020/class-FoDOS-experiment-3/
第四次:https://shiraha.cn/2020/class-FoDOS-experiment-4/
Front-matter
本次实验的所有源代码可以在https://dev.azure.com/Pure-Asahi/_git/2020_Spring_In_Class_Job?path=%2Fosnmb%2Fexp 查看到。
实验要求
本次上机实验的实验要求如下:
《操作系统原理》第二次上机实验 一、实验目的
- 理解操作系统线程的概念和应用编程过程;
- 理解线程的同步概念和编程;
二、实验内容
- 在 Ubuntu 或 Fedora 环境使用 fork 函数创建一对父子进程,分别输出各自的进程号和提示信息串。
- 在 Ubuntu 或 Fedora 环境使用 pthread_create 函数创建 2 个线程 A 和 B。线程 A 在屏幕上用 while 循环顺序递增地输出 1-1000 的自然数;线程 B 在屏幕上用 while 循环顺序递减地输出 1000-1 之间的自然数。为避免输出太快,每隔 0.5 秒输出一个数。
- 在 windows 环境下,利用高级语言编程环境(限定为 VS 环境或 VC 环境)调用 CreateThread 函数实现(2)的功能。
- 在 windows 环境下,利用高级语言编程环境(限定为 VS 环境或 VC 环境)调用 CreateThread 函数和相关的同步函数,模拟实现“生产者-消费者”问题。
- 在 windows 环境下,利用高级语言编程环境(限定为 VS 环境或 VC 环境)调用 CreateThread 函数实现“并发地画圆和画方”。圆的中心,半径,颜色,正方形的中心,边长,颜色等参数自己确定,合适就行。圆和正方形的边界上建议取 720 个点。为直观展示绘制的过程,每个点绘制后睡眠 0.2 秒~0.5 秒。
- 在 windows 环境下,利用高级语言编程环境(限定为 VS 环境或 VC 环境)调用 CreateThread 函数实现“文件拷贝小工具”。功能如下:
- 具有一个编辑框,让用户任意指定源目录或文件
- 具有一个编辑框,让用户任意指定目的目录或文件
- 具有“开始拷贝”按钮
- 具有“停止拷贝”按钮
- 具有显示拷贝进度的 label,当为目录拷贝时以文件数来统计进度,当为文件拷贝时以字节数来统计进度。
以上六个题目中,选择四个完成(1,2 中任意 1 题和 3,4,5 或 3,4,6 共计 4 道题);
我选择了 1、2、3、4、5 五个题目。
实验内容
- 在 Ubuntu 或 Fedora 环境使用 fork 函数创建一对父子进程,分别输出各自的进程号和提示信息串。
- 在 Ubuntu 或 Fedora 环境使用 pthread_create 函数创建 2 个线程 A 和 B。线程 A 在屏幕上用 while 循环顺序递增地输出 1-1000 的自然数;线程 B 在屏幕上用 while 循环顺序递减地输出 1000-1 之间的自然数。为避免输出太快,每隔 0.5 秒输出一个数。
- 在 windows 环境下,利用高级语言编程环境(限定为 VS 环境或 VC 环境)调用 CreateThread 函数实现(2)的功能。
- 在 windows 环境下,利用高级语言编程环境(限定为 VS 环境或 VC 环境)调用 CreateThread 函数和相关的同步函数,模拟实现“生产者-消费者”问题。
- 在 windows 环境下,利用高级语言编程环境(限定为 VS 环境或 VC 环境)调用 CreateThread 函数实现“并发地画圆和画方”。圆的中心,半径,颜色,正方形的中心,边长,颜色等参数自己确定,合适就行。圆和正方形的边界上建议取 720 个点。为直观展示绘制的过程,每个点绘制后睡眠 0.2 秒~0.5 秒。
实验过程
下面是对于每一个任务的原理和过程的简述:
父子进程
在 Linux 中,可以使用 C 语言函数式的系统调用 getpid
获得当前进程的 ID,可以使用getppid
获得可能存在的父亲进程 ID。为了存储这些函数返回的中间值,可能还需要引入sys/type.h
。
系统调用fork
可以复制当前的进程并创建一个一样的进程,子进程的控制流从调用该函数的位置开始;它的返回值可能有如下三种情况:
- 在父亲进程中,该函数返回子进程的 pid。
- 在孩子进程中,该函数返回 0.
- 出现错误时,该函数设置
errno
位并且返回负值。
一般来说fork
函数是不会出错的,它仅会在两种情况下出错:要不是当前的进程数已经达到了系统规定的上限,这时errno
的值被设置为EAGAIN
;要不是系统内存不足,这时errno
的值被设置为ENOMEM
。
正因为fork
函数的返回值有着上面所说的特性,所以可以在不同的进程内使用它的返回值来判断当前所属的进程是父亲还是孩子。
这些函数定义在unistd.h
头文件中;在 VC++6.0 中,可以在 process.h
中找到。
这样,实验任务的核心代码就可以这么写:
1 | pid_t son = fork(); |
返回值不为 0,就是父亲进程,否则就是儿子进程。在这个实验里这样做就够了。
创建线程 - Linux
在Linux环境下创建线程使用的系统调用是pthread_create
,它定义在pthread.h
中;声明是下面这样子的:
1 | int pthread_create( |
每个参数的含义是这样的:
- 是新创建的线程ID指向的内存单元,也就是指向线程标识符的指针;这是一个out属性,该函数为它赋值为创建的新进程的标识符;利用这个标识符可以做一些其他的事情。
- 用来设置线程的属性。默认值为
NULL
,我们一般也直接使用默认值。 - 新创建的程序要运行的函数首址,也就是一个函数指针。这个函数必须接受一个
void*
指针作为参数,并返回一个void*
作为返回值。当然,你也可以强制类型转换。 - 是上面那个函数运行需要的参数,当然是一个
void*
指针。
一般来说,调用此函数时应当避免传入会被其它线程修改的变量作为参数。一般都是先memcpy
之后再将复制品的首地址作为参数传入。此外,第二个参数缺省的时候创建的线程是非分离属性的,这意味着它结束的时候,它所占用的系统资源并没有真正的释放。只有使用pthread_join
返回后,或者这个线程指定为分离属性时,才可以保证线程占用的资源被释放。
此外,还可以使用pthread_join
函数获得线程函数的返回值,它的声明如下:
1 | int pthread_join __P (pthread_t __th, void **__thread_return); |
它可以用来等待一个线程的结束并且获得返回值:第一个参数是线程标识符,也就是上面pthread_create
函数通过第一个参数传出的标识符;第二个参数是线程函数的返回值指针的指针,是一个out属性。
这个函数是一个阻塞函数,如果某线程调用此函数等待另一个线程,那么该线程将持续阻塞直到被等待的线程结束并给出返回值;函数返回后,被等待线程的资源被收回,返回 0;如果此函数出错,将会返回错误号。
综上所述,本题的核心代码可以这么写:
1 | pthread_t sign_A, sign_B; |
threadA
和threadB
并没有定义为上述参数类型的函数;但是因为参数、返回值的大小是一致的,可以安全的使用强制类型转换转为指定类型的函数。
此外,因为pthread
并不是Linux系统的默认库,所以如果使用命令行编译,需要加上编译指令-lpthread
来链接相关的库;如果您使用 CMake 管理您的项目,还需要在 CMakeList.txt 中加上下面的内容:
1 | add_compile_options(-lpthread) |
其中,main 是你的项目目标名,可以在这个文档中找到。
创建线程 - Windows
在Windows中创建子进程需要用到CreateThread
函数;在不使用CRT库的情况下,一般需要通过这个函数创建新进程;并使用WaitForMultipleObjects
或者WaitForSingleObject
等待线程的结束;使用GetExitCodeThread
获得线程函数的返回值;并且使用CloseHandle
关闭创建的线程对象。
CreateThread
函数定义在processthreadsapi.h
中,但是我们只需要通过引入windows.h
来引入就好了;这个函数的声明式是下面这个样子的:
1 | HANDLE WINAPI CreateThread( |
一共接受六个参数,它们的含义如下:
- 表示线程内核对象的安全属性,一般使用
NULL
表示采用默认值:不可以被子线程继承 - 设置新线程的初始栈大小(B),0 表示和调用线程相同(默认1MB);因为Windows会根据需要自动延长栈空间,所以一般传入 0 就好了。
- 指向线程函数的指针,函数类型是
function<DWORD WINAPI (LPVOID>
,多个线程可以共用一个函数;实际函数也可以不接受参数不返回,也可强制转换为LPTHREAD_START_ROUTINE
类型使用。 - 传递给上面函数的参数,是
void*
类型;一般指向结构体,或者传入NULL
表示不传参。 - 线程标志,用来控制新线程的创建:0 表示创建后立即调度,
CREATE_SUSPENDED
表明创建后立即挂起,直到函数ResumeThread
被调用;还有一些其他的可选值。 - 是一个out属性,用来保存新线程的 ID。
当线程创建成功时,函数返回一个表示新线程对象的HANDLE;否则返回false。
虽然此函数有很多更推荐的替代,比如BeginThread
;但是这里直接使用它就够了。
WaitForSingleObject
函数用来等待线程:当指定线程(对象)处于有信号状态或者等待时间结束的状态时,此函数会返回;它的声明如下:
1 | DWORD WaitForSingleObject( |
第一个参数是指向对象的HANDLE,第二个参数是等待的时间;这个函数使得当前线程进入等待状态,直到它返回;只可以等待HANDLE指定的那个支持被通知/未通知的内核对象,等待时间由第二个参数指定,一般使用INFINITE
作为等待无穷时间;但是如果等待对象永远不改变状态,那么调用线程将不会被唤醒,但是也不会浪费CPU时间。
若对象变为已通知状态,则函数返回WAIT_OBJECT_0
;若等待时间超过了设定的时间,返回WAIT_TIMEOUT
;若函数错误(比如将一个无效句柄传给了它),则返回WAIT_FAILED
,此时可以用GetLastError
获得详细信息。
WaitForMultipleObjects
则和上面的函数很像,但是可以同时等待多个内核对象,它的声明如下:
1 | DWORD WaitForMultipleObjects( |
它的四个参数的含义如下:
- 需要让此函数等待的内核对象数量,范围在[1, MAXIMU M_WAIT_OBJECTS]内。
- 是内核对象HANDLE的数组,也就是数组第一个对象的指针。
- 表示此函数的工作方式:
TRUE
表示所有对象变为已通知状态后才返回;FALSE
状态表示任一个对象状态变为已通知之后就返回。 - 等待时间,和
WaitForSingleObject
中的第二个参数一样。
使用的限制也和上面的函数大体相同。
GetExitCodeThread
可以用于获取一个已经退出/中止的的线程对象的退出代码。比起说“获取”,“检查”应该更加合适:它不同于上面的两个等待的函数会使得调用线程进入等待状态,当它发现待检查的线程对象仍然活动的时候,它就会立即退出,而不阻塞当前进程(所以循环的调用这个函数是一种阻塞的等待)。它的声明如下:
1 | BOOL GetExitCodeThread ( |
第一个参数是指向线程对象的HANDLE;第二个对象是一个out属性,用来储存线程结束的代码或返回值。返回值仅表示该函数是否调用成功,并不代表线程对象是否已经中止。
若检查的HANDLE代表的线程仍然在运行,那么这个函数会为第二个参数赋值STILL_ACTIVE
;否则将函数的返回值赋值给它;一个危险的状况是,当函数返回值就是STILL_ACTIVE
时,此函数会失效。因为LPDWORD
大小有限,所以线程函数的返回值最好是一个指向结构体的指针。
最后,因为Windows中,线程执行完毕中止后线程对象仍然存在,所以需要使用CloseHandle
手动关闭句柄。这个函数的声明式是这样的:
1 | BOOL CloseHandle(HANDLE hObject); |
参数是一个用来表示已经打开的对象(包括线程)的HANDLE;返回值仅表示函数执行成功与否,返回FALSE
时可以通过GetLastError
来获得错误原因。
这个函数实质的工作就是减少该内核对象的引用次数 1,而不是直接关闭对象——当一个对象引用计数为 0 或进程结束时会自动被操作系统回收,所以可能存在调用该函数后系统对象仍然存在以至于泄露的情况。
综上所述,已经理清楚在Windows中一个线程的生老病死的全过程;所以核心代码可以这么写:
1 | parameter para_A = {114514}, para_B = {1919810}; |
其实额外的检查内核对象状态在这里没啥意义……毕竟出了错也只能EXIT(EXIT_FAILURE)
。
生产者 - 消费者
关于生产者-消费者问题:又称为有限缓冲问题。两个线程,也就是生产者和消费者线程公用同一个固定大小的缓冲区;生产者会向其中放入数据,消费者会从中取出数据;需要保证的是生产者在缓冲区满后不会再继续加入数据,消费者在缓冲区空的时候不会继续取出数据,并且回避出现死锁的情况。这里我们使用信号量机制来实现这个问题的模拟。
显然,缓冲区可以看作这个问题的临界区;而Windows引入提供了专门的临界区对象:
1 | CRITICAL_SECTION cs; //声明临界区对象 |
此外,Windows还有定义了的信号量相关的函数,可用来模拟信号量完成对于临界资源的 P-V 操作:
CreateSemaphore
函数可以创建一个信号量,并且返回用来代表它的HANDLE:
1 | HANDLE CreateSemaphore( |
它接受的参数的含义是这样的:
- 代表了信号量的属性;设置为
NULL
时取默认值 - 信号量的初始值,要求在[0, lpMaximumCount]范围内;=0时信号量默认处于 unsignal 状态,否则为 signal 状态。
- 代表信号量可到达的最大值,必须为正数。
- 信号量的名字,是 C 字符串;字符串的长度不能超过
MAX_PATH
,设置为NULL
表示无名信号量;如果传入的字符串已经代表了一个信号量,则直接打开它。
函数调用成功后将返回新建信号量的 HANDLE。
OpenSemaphore
函数可以打开一个已经存在的信号量,它的声明如下:
1 | HANDLE OpenSemaphore( |
这个函数接受的参数的含义是这样的:
- 描述了对于信号量的访问权限;
SEMAPHORE_ALL_ACCESS
,表示可对信号量执行尽可能多的操作;SEMAPHORE_MODIFY_STATE
,表示允许使用ReleaseSemaphore
释放信号量,以修改信号量;SYNCHRONIZE
,表示用等待函数异步的等待信号量变为signal状态。 - 表示信号量的可继承性;
TRUE
表示该 HANDLE 可以被继承。 - 信号量的名字,是 C 字符串;
由上述参数描述可以看出,这个函数只可以用来打开一个有名字的信号量。
此外,还有上面描述中提及的用于释放信号量的函数ReleaseSemaphore
:
1 | BOOL ReleaseSemaphore( |
第一个参数是要操作的信号量的 HANDLE;第二个参数是要在此信号量中释放的数量;第三个参数是一个out参数,用来存储操作前信号量的数量,如果不需要也可以传入NULL
;
当信号量使用完毕之后,它也要像其他的内核对象那样使用CloseHandle
函数关闭 HANDLE。
综上所述,我们可以使用信号量的释放模拟问题中的V操作,使用对于信号量 HANDLE 的等待作为问题中的P操作,就可以模拟这个问题了。代码如下:
1 |
|
画圆画方
根据上面的任务中了解到的 Windows 环境下创建进程所需要的知识,我们仅需要定义好画圆和画方的函数,作为进程函数传给CreateThread
函数就可以了。这部分的函数我使用了 Easy X 图形库完成。它只支持在 Visual Studio / Visual C++ 环境下使用。在使用前你可能需要去 Easy X 官网上去下载它最新的安装包安装后才可以正常编译该源代码。
下面是画圆和画方的线程函数:
1 | DWORD WINAPI drawSquare(LPVOID) |
实验时只需要将它们分别交给两个线程执行就可以了。
实验结果
这里是任务完成后的截图或其他证明。
任务一:父子进程
任务二:多线程 - Linux
任务三:多线程 - Windows
任务四:生产者 - 消费者
任务五:画圆画方
体会
通过本次的操作系统原理实验,我熟悉了在 Linux 上和 Windows 上和线程相关的一些函数的使用;熟练了面向操作系统的编程,可以将一些基本的功能需求使用多线程技术实现。从同一个功能在 Linux 和 Windows 平台上实现的不同,进一步地体会了两个操作系统之间的差异。
实验的过程中也遇到了一些看起来不太容易解释的问题,这说明要想学好面向操作系统的多线程编程,仅仅了解函数的声明式是远远不够的。除了表面的 API 调用之外,还应该更多的去了解函数内部的构造以及实现原理。在处理这些问题的过程中,加深了我对这些原理的理解。