系统调用
我们已经可以看到操作系统提供了两种功能:为用户提供应用程序抽象和管理计算机资源。对于大部分在应用程序和操作系统之间的交互主要是应用程序的抽象,例如创建、写入、读取和删除文件。计算机的资源管理对用户来说基本上是透明的。因此,用户程序和操作系统之间的接口主要是处理抽象。为了真正理解操作系统的行为,我们必须仔细的分析这个接口。
多数现代操作系统都有功能相同但是细节不同的系统调用,引发操作系统的调用依赖于计算机自身的机制,而且必须用汇编代码表达。任何单 CPU 计算机一次执行执行一条指令。如果一个进程在用户态下运行用户程序,例如从文件中读取数据。那么如果想要把控制权交给操作系统控制,那么必须执行一个异常指令或者系统调用指令。操作系统紧接着需要参数检查找出所需要的调用进程。操作系统紧接着进行参数检查找出所需要的调用进程。然后执行系统调用,把控制权移交给系统调用下面的指令。大致来说,系统调用就像是执行了一个特殊的过程调用,但是只有系统调用能够进入内核态而过程调用则不能进入内核态。
为了能够了解具体的调用过程,下面我们以 read
方法为例来看一下调用过程。像上面提到的那样,会有三个参数,第一个参数是指定文件、第二个是指向缓冲区、第三个参数是给定需要读取的字节数。就像几乎所有系统调用一样,它通过使用与系统调用相同的名称来调用一个函数库,从而从C程序中调用:read。
count = read(fd,buffer,nbytes);
系统调用在 count 中返回实际读出的字节数。这个值通常与 nbytes 相同,但也可能更小。比如在读过程中遇到了文件尾的情况。
如果系统调用不能执行,不管是因为无效的参数还是磁盘错误,count 的值都会被置成 -1,然后在全局变量 errno
中放入错误信号。程序应该进场检查系统调用的结果以了解是否出错。
系统调用是通过一系列的步骤实现的,为了更清楚的说明这个概念,我们还以 read 调用为例,在准备系统调用前,首先会把参数压入堆栈,如下所示
C 和 C++ 编译器使用逆序(必须把第一个参数赋值给 printf(格式字符串),放在堆栈的顶部)。第一个参数和第三个参数都是值调用,但是第二个参数通过引用传递,即传递的是缓冲区的地址(由 & 指示),而不是缓冲的内容。然后是 C 调用系统库的 read 函数,这也是第四步。
在由汇编语言写成的库过程中,一般把系统调用的编号放在操作系统所期望的地方,如寄存器(第五步)。然后执行一个 TRAP
指令,将用户态切换到内核态,并在内核中的一个固定地址开始执行第六步。TRAP 指令实际上与过程调用指令非常相似,它们后面都跟随一个来自远处位置的指令,以及供以后使用的一个保存在栈中的返回地址。
TRAP 指令与过程调用指令存在两个方面的不同
- TRAP 指令会改变操作系统的状态,由用户态切换到内核态,而过程调用不改变模式
- 其次,TRAP 指令不能跳转到任意地址上。根据机器的体系结构,要么跳转到一个单固定地址上,或者指令中有一 8 位长的字段,它给定了内存中一张表格的索引,这张表格中含有跳转地址,然后跳转到指定地址上。
跟随在 TRAP 指令后的内核代码开始检查系统调用编号,然后dispatch
给正确的系统调用处理器,这通常是通过一张由系统调用编号所引用的、指向系统调用处理器的指针表来完成第七步。此时,系统调用处理器运行第八步,一旦系统调用处理器完成工作,控制权会根据 TRAP 指令后面的指令中返回给函数调用库第九步。这个过程接着以通常的过程调用返回的方式,返回到客户应用程序,这是第十步。然后调用完成后,操作系统还必须清除用户堆栈,然后增加堆栈指针(increment stackpointer)
,用来清除调用 read 之前压入的参数。从而完成整个 read 调用过程。
在上面的第九步中我们说道,控制可能返回 TRAP 指令后面的指令,把控制权再移交给调用者这个过程中,系统调用会发生阻塞,从而避免应用程序继续执行。这么做是有原因的。例如,如果试图读键盘,此时并没有任何输入,那么调用者就必须被阻塞。在这种情形下,操作系统会检查是否有其他可以运行的进程。这样,当有用户输入 时候,进程会提醒操作系统,然后返回第 9 步继续运行。
下面,我们会列出一些常用的 POSIX
系统调用,POSIX 系统调用大概有 100 多个,它们之中最重要的一些调用见下表
进程管理
调用 | 说明 |
pid = fork() | 创建与父进程相同的子进程 |
pid = waitpid(pid, &statloc,options) | 等待一个子进程终止 |
s = execve(name,argv,environp) | 替换一个进程的核心映像 |
exit(status) | 终止进程执行并返回状态 |
文件管理
调用 | 说明 |
fd = open(file, how,...) | 打开一个文件使用读、写 |
s = close(fd) | 关闭一个打开的文件 |
n = read(fd,buffer,nbytes) | 把数据从一个文件读到缓冲区中 |
n = write(fd,buffer,nbytes) | 把数据从缓冲区写到一个文件中 |
position = iseek(fd,offset,whence) | 移动文件指针 |
s = stat(name,&buf) | 取得文件状态信息 |
目录和文件系统管理
调用 | 说明 |
s = mkdir(nname,mode) | 创建一个新目录 |
s = rmdir(name) | 删去一个空目录 |
s = link(name1,name2) | 创建一个新目录项 name2,并指向 name1 |
s = unlink(name) | 删去一个目录项 |
s = mount(special,name,flag) | 安装一个文件系统 |
s = umount(special) | 卸载一个文件系统 |
其他
调用 | 说明 |
s = chdir(dirname) | 改变工作目录 |
s = chmod(name,mode) | 修改一个文件的保护位 |
s = kill(pid, signal) | 发送信号给进程 |
seconds = time(&seconds) | 获取从 1970 年1月1日至今的时间 |
上面的系统调用参数中有一些公共部分,例如 pid 系统进程 id,fd 是文件描述符,n 是字节数,position 是在文件中的偏移量、seconds 是流逝时间。
从宏观角度上看,这些系统调所提供的服务确定了多数操作系统应该具有的功能,下面分别来对不同的系统调用进行解释
用于进程管理的系统调用
在 UNIX 中,fork
是唯一可以在 POSIX 中创建进程的途径,它创建一个原有进程的副本,包括所有的文件描述符、寄存器等内容。在 fork 之后,原有进程以及副本(父与子)就分开了。在 fork 过程中,所有的变量都有相同的值,虽然父进程的数据通过复制给子进程,但是后续对其中任何一个进程的修改不会影响到另外一个。fork 调用会返回一个值,在子进程中该值为 0 ,并且在父进程中等于子进程的 进程标识符(Process IDentified,PID)
。使用返回的 PID,就可以看出来哪个是父进程和子进程。
在多数情况下, 在 fork 之后,子进程需要执行和父进程不一样的代码。从终端读取命令,创建一个子进程,等待子进程执行命令,当子进程结束后再读取下一个输入的指令。为了等待子进程完成,父进程需要执行 waitpid
系统调用,父进程会等待直至子进程终止(若有多个子进程的话,则直至任何一个子进程终止)。waitpid 可以等待一个特定的子进程,或者通过将第一个参数设为 -1 的方式,等待任何一个比较老的子进程。当 waitpid 完成后,会将第二个参数 statloc
所指向的地址设置为子进程的退出状态(正常或异常终止以及退出值)。有各种可使用的选项,它们由第三个参数确定。例如,如果没有已经退出的子进程则立刻返回。
那么 shell 该如何使用 fork 呢?在键入一条命令后,shell 会调用 fork 命令创建一个新的进程。这个子进程会执行用户的指令。通过使用 execve
系统调用可以实现系统执行,这个系统调用会引起整个核心映像被一个文件所替代,该文件由第一个参数给定。下面是一个简化版的例子说明 fork、waitpid 和 execve 的使用
#define TRUE 1 /* 一直循环下去 */ while(TRUE){ /* 在屏幕上显示提示符 */ type_prompt(); /* 从终端读取输入 */ read_command(command,parameters) /* fork 子进程 */ if(fork() != 0){ /* 父代码 */ /* 等待子进程执行完毕 */ waitpid(-1, &status, 0); }else{ /* 执行命令 */ /* 子代码 */ execve(command,parameters,0) } }
一般情况下,execve 有三个参数:将要执行的文件名称,一个指向变量数组的指针,以及一个指向环境数组的指针。这里对这些参数做一个简要的说明。
先看一个 shell 指令
cp file1 file2
此命令把 file1 复制到 file2 文件中,在 shell 执行 fork 之后,子进程定位并执行文件拷贝,并将源文件和目标文件的名称传递给它。
cp 的主程序(以及包含其他大多数 C 程序的主程序)包含声明
main(argc,argv,envp)
其中 argc 是命令行中参数数目的计数,包括程序名称。对于上面的例子,argc
是3。第二个参数argv
是数组的指针。该数组的元素 i 是指向该命令行第 i 个字符串的指针。在上面的例子中,argv[0] 指向字符串 cp,argv[1] 指向字符串 file1,argv[2] 指向字符串 file2。main 的第三个参数是指向环境的指针,该环境是一个数组,含有 name = value
的赋值形式,用以将诸如终端类型以及根目录等信息传送给程序。这些变量通常用来确定用户希望如何完成特定的任务(例如,使用默认打印机)。在上面的例子中,没有环境参数传递给 execve ,所以环境变量是 0 ,所以 execve 的第三个参数为 0 。
可能你觉得 execve 过于复杂,这时候我要鼓励一下你,execve 可能是 POSIX 的全部系统调用中最复杂的一个了,其他都比较简单。作为一个简单的例子,我们再来看一下 exit
,这是进程在执行完成后应执行的系统调用。这个系统调用有一个参数,它的退出状态是 0 - 255 之间,它通过 waitpid 系统调用中的 statloc 返回给父级。
UNIX 中的进程将内存划分成三个部分:text segment,文本区
,例如程序代码,data segment,数据区
,例如变量,stack segment
,栈区域。数据向上增长而堆栈向下增长,如下图所示
上图能说明三个部分的内存分配情况,夹在中间的是空闲区,也就是未分配的区域,堆栈在需要时自动的挤压空闲区域,不过数据段的扩展是显示地通过系统调用 brk
进行的,在数据段扩充后,该系统调用指向一个新地址。但是,这个调用不是 POSIX 标准中定义的,对于存储器的动态分配,鼓励程序员使用 malloc
函数,而 malloc 的内部实现则不是一个适合标准化的主题,因为几乎没有程序员直接使用它。
用于文件管理的系统调用
许多系统调用都与文件系统有关,要读写一个文件,必须先将其打开。这个系统调用通过绝对路径名或指向工作目录的相对路径名指定要打开文件的名称,而代码 O_RDONLY
、 O_WRONLY
或 O_RDWR
的含义分别是只读、只写或者两者都可以,为了创建一个新文件,使用 O_CREATE
参数。然后可使用返回的文件描述符进行读写操作。接着,可以使用 close 关闭文件,这个调用使得文件描述符在后续的 open 中被再次使用。
最常用的调用还是 read
和 write
,我们再前面探讨过 read 调用,write 具有与 read 相同的参数。
尽管多数程序频繁的读写文件,但是仍有一些应用程序需要能够随机访问一个文件的任意部分。与每个文件相关的是一个指向文件当前位置的指针。在顺序读写时,该指针通常指向要读出(写入)的下一个字节。Iseek
调用可以改变该位置指针的值,这样后续的 read 或 write 调用就可以在文件的任何地方开始。
Iseek 有三个参数,position = iseek(fd,offset,whence)
,第一个是文件描述符,第二个是文件位置,第三个是说明该文件位置是相对于文件起始位置,当前位置还是文件的结尾。在修改了指针之后,Iseek 所返回的值是文件中的绝对位置。
UNIX 为每个文件保存了该文件的类型(普通文件、特殊文件、目录等)、大小,最后修改时间以及其他信息,程序可以通过 stat
系统调用查看这些信息。s = stat(name,&buf)
,第一个参数指定了被检查的文件;第二个参数是一个指针,该指针指向存放这些信息的结构。对于一个打开的文件而言,fstat 调用完成同样的工作。
用于目录管理的系统调用
下面我们探讨目录和整个文件系统的系统调用,上面探讨的是和某个文件有关的系统调用。mkdir
和 rmdir
分别用于创建s = mkdir(nname,mode)
和删除 s = rmdir(name)
空目录,下一个调用是 s = link(name1,name2)
它的作用是允许同一个文件以两个或者多个名称出现,多数情况下是在不同的目录中使用 link ,下面我们探讨一下 link 是如何工作的
图中有两个用户 ast
和 jim
,每个用户都有他自己的一个目录和一些文件,如果 ast 要执行一个包含下面系统调用的应用程序
link("/usr/jim/memo", "/usr/ast/note");
jim 中的 memo 文件现在会进入到 ast 的目录中,在 note 名称下。此后,/usr/jim/memo
和 /usr/ast/note
会有相同的名称。
“用户目录是保存在 /usr,/user,/home 还是其他位置,都是由本地系统管理员决定的。
要理解 link 是如何工作的需要清楚 link 做了什么操作。UNIX 中的每个文件都有一个独一无二的版本,也称作 i - number,i-编号
,它标示着不同文件的版本。这个 i - 编号是 i-nodes,i-节点
表的索引。每个文件都会表明谁拥有这个文件,这个磁盘块的位置在哪,等等。目录只是一个包含一组(i编号,ASCII名称)对应的文件。UNIX 中的第一个版本中,每个目录项都会有 16 个字节,2 个字节对应 i - 编号和 14 个字节对应其名称。现在需要一个更复杂的结构需要支持长文件名,但是从概念上讲一个目录仍是一系列(i-编号,ASCII 名称)的集合。在上图中,mail
的 i-编号为 16,依此类推。link 只是利用某个已有文件的 i-编号,创建一个新目录项(也许用一个新名称)。在上图 b 中,你会发现有两个相同的 70 i-编号的文件,因此它们需要有相同的文件。如果其中一个使用了 unlink
系统调用的话,其中一个会被移除,另一个将保留。如果两个文件都移除了,则 UNIX 会发现该文件不存在任何没有目录项(i-节点中的一个域记录着指向该文件的目录项),就会把该文件从磁盘中移除。
就像我们上面提到过的那样,mount
系统 s = mount(special,name,flag)
调用会将两个文件系统合并为一个。通常的情况是将根文件系统分布在硬盘(子)分区上,并将用户文件分布在另一个(子)分区上,该根文件系统包含常用命令的二进制(可执行)版本和其他使用频繁的文件。然后,用户就会插入可读取的 USB 硬盘。
通过执行 mount 系统调用,USB 文件系统可以被添加到根文件系统中,
如果用 C 语言来执行那就是
mount("/dev/sdb0","/mnt",0)
这里,第一个参数是 USB 驱动器 0 的块特殊文件名称,第二个参数是被安装在树中的位置,第三个参数说明将要安装的文件系统是可读写的还是只读的。
当不再需要一个文件系统时,可以使用 umount 移除之。
其他系统调用
除了进程、文件、目录系统调用,也存在其他系统调用的情况,下面我们来探讨一下。我们可以看到上面其他系统调用只有四种,首先来看第一个 chdir,chdir 调用更改当前工作目录,在调用
chdir("/usr/ast/test");
后,打开 xyz 文件,会打开 /usr/ast/test/xyz
文件,工作目录的概念消除了总是需要输入长文件名的需要。
在 UNIX 系统中,每个文件都会有保护模式,这个模式会有一个读-写-执行
位,它用来区分所有者、组和其他成员。chmod
系统调用提供改变文件模式的操作。例如,要使一个文件除了对所有者之外的用户可读,你可以执行
chmod("file",0644);
kill
系统调用是用户和用户进程发送信号的方式,如果一个进程准备好捕捉一个特定的信号,那么在信号捕捉之前,会运行一个信号处理程序。如果进程没有准备好捕捉特定的信号,那么信号的到来会杀掉该进程(此名字的由来)。
POSIX 定义了若干时间处理的进程。例如,time
以秒为单位返回当前时间,0 对应着 1970 年 1月 1日。在一台 32 位字的计算机中,time 的最大值是 (2^32) - 1秒,这个数字对应 136 年多一点。所以在 2106 年,32 位的 UNIX 系统会发飙。如果读者现在有 32 位 UNIX 系统,建议在 2106 年更换位 64 位操作系统(偷笑~)。
Win 32 API
上面我们提到的都是 UNIX 系统调用,现在我们来聊聊 Win 32 中的系统调用。Windows 和 UNIX 在各自的编程方式上有着根本的不同。UNIX 程序由执行某些操作或执行其他操作的代码组成,进行系统调用以执行某些服务。Windows 系统则不同,Windows 应用程序通常是由事件驱动的。主程序会等待一些事件发生,然后调用程序去处理。最简单的事件处理是键盘敲击和鼠标滑过,或者是鼠标点击,或者是插入 USB 驱动,然后操作系统调用处理器去处理事件,更新屏幕和更新程序内部状态。这是与 UNIX 不同的设计风格。
当然,Windows 也有系统调用。在 UNIX 中,系统调用(比如 read)和系统调用所使用的调用库(例如 read)几乎是一对一的关系。而在 Windows 中,情况则大不相同。首先,函数库的调用和实际的系统调用几乎是不对应的。微软定义了一系列过程,称为 Win32应用编程接口(Application Programming Interface)
,程序员通过这套标准的接口来实现系统调用。这个接口支持从 Windows 95 版本以来所有的 Windows 版本。
Win32 API 调用的数量是非常巨大的,有数千个多。但这些调用并不都是在内核态的模式下运行时,有一些是在用户态的模型下运行。Win32 API 有大量的调用,用来管理视窗、几何图形、文本、字体、滚动条、对话框、菜单以及 GUI 的其他功能。为了使图形子系统在内核态下运行,需要系统调用,否则就只有函数库调用。
我们把关注点放在和 Win32 系统调用中来,我们可以简单看一下 Win32 API 中的系统调用和 UNIX 中有什么不同(并不是所有的系统调用)
上表中是 UNIX 调用大致对应的 Win32 API 系统调用,简述一下上表。CreateProcess
用于创建一个新进程,它把 UNIX 中的 fork 和 execve 两个指令合成一个,一起执行。它有许多参数用来指定新创建进程的性质。Windows 中没有类似 UNIX 中的进程层次,所以不存在父进程和子进程的概念。在进程创建之后,创建者和被创建者是平等的。WaitForSingleObject
用于等待一个事件,等待的事件可以是多种可能的事件。如果有参数指定了某个进程,那么调用者将等待指定的进程退出,这通过 ExitProcess
来完成。
然后是6个文件操作,在功能上和 UNIX 的调用类似,然而在参数和细节上是不同的。和 UNIX 中一样,文件可以打开,读取,写入,关闭。SetFilePointer
和 GetFileAttributesEx
设置文件的位置并取得文件的属性。
Windows 中有目录,目录分别用 CreateDirectory
以及 RemoveDirectory
API 调用创建和删除。也有对当前的目录的标记,这可以通过 SetCurrentDirectory
来设置。使用GetLocalTime
可获得当前时间。
Win32 接口中没有文件的链接、文件系统的 mount、umount 和 stat ,当然, Win32 中也有大量 UNIX 中没有的系统调用,特别是对 GUI 的管理和调用。
操作系统结构
下面我们会探讨操作系统的几种结构,主要包括单体结构、分层系统、微内核、客户-服务端系统、虚拟机和外核等。下面以此来探讨一下
单体系统
到目前为止,在大多数系统中,整个系统在内核态以单一程序的方式运行。整个操作系统是以程序集合来编写的,链接在一块形成一个大的二进制可执行程序。使用此技术时,如果系统中的每个过程都提供了前者所需的一些有用的计算,则它可以自由调用任何其他过程。在单体系统中,调用任何一个所需要的程序都非常高效,但是上千个不受限制的彼此调用往往非常臃肿和笨拙,而且单体系统必然存在单体问题,那就是只要系统发生故障,那么任何系统和应用程序将不可用,这往往是灾难性的。
在单体系统中构造实际目标程序时,会首先编译所有单个过程(或包含这些过程的文件),然后使用系统链接器将它们全部绑定到一个可执行文件中
对于单体系统,往往有下面几种建议
- 需要有一个主程序,用来调用请求服务程序
- 需要一套服务过程,用来执行系统调用
- 需要一套服务程序,用来辅助服务过程调用
在单体系统中,对于每个系统调用都会有一个服务程序来保障和运行。需要一组实用程序来弥补服务程序需要的功能,例如从用户程序中获取数据。可将各种过程划分为一个三层模型
除了在计算机初启动时所装载的核心操作系统外,许多操作系统还支持额外的扩展。比如 I/O 设备驱动和文件系统。这些部件可以按需装载。在 UNIX 中把它们叫做 共享库(shared library)
,在 Windows 中则被称为 动态链接库(Dynamic Link Library,DLL)
。他们的扩展名为 .dll
,在 C:\Windows\system32
目录下存在 1000 多个 DLL 文件,所以不要轻易删除 C 盘文件,否则可能就炸了哦。
分层系统
分层系统使用层来分隔不同的功能单元。每一层只与该层的上层和下层通信。每一层都使用下面的层来执行其功能。层之间的通信通过预定义的固定接口通信。
分层系统是由 E.W.Dijkstar
和他的学生在荷兰技术学院所开发的 THE 系统。
把上面单体系统进一步通用化,就变为了一个层次式结构的操作系统,它的上层软件都是在下层软件的基础之上构建的。该系统分为六层,如下所示
层号 | 功能 |
5 | 操作员 |
4 | 用户程序 |
3 | 输入/输出管理 |
2 | 操作员-进程通信 |
1 | 存储器和磁鼓管理 |
0 | 处理器分配和多道程序编程 |
处理器在 0 层运行,当中断发生或定时器到期时,由该层完成进程切换;在第 0 层之上,系统由一些连续的进程组成,编写这些进程时不用再考虑在单处理器上多进程运行的细节。内存管理在第 1 层,它分配进程的主存空间。第 1 层软件保证一旦需要访问某一页面,该页面必定已经在内存中,并且在页面不需要的时候将其移出。
第 2 层处理进程与操作员控制台(即用户)之间的通信。第 3 层管理 I/O 设备和相关的信息流缓冲区。第 4 层是用户程序层,用户程序不用考虑进程、内存、控制台或 I/O 设备管理等细节。系统操作员在第 5 层。
微内核
在分层方式中,设计者要确定在哪里划分 内核-用户
的边界。传统上,所有的层都在内核中,但是这样做没有必要。事实上,尽可能减少内核态中功能可能是更好的做法。因为内核中的错误很难处理,一旦内核态中出错误会拖累整个系统。
所以,为了实现高可靠性,将操作系统划分成小的、层级之间能够更好定义的模块是很有必要的,只有一个模块 --- 微内核 --- 运行在内核态,其余模块可以作为普通用户进程运行。由于把每个设备驱动和文件系统分别作为普通用户进程,这些模块中的错误虽然会使这些模块崩溃,但是不会使整个系统死机。
MINIX 3
是微内核的代表作,它的具体结构如下
在内核的外部,系统的构造有三层,它们都在用户态下运行,最底层是设备驱动器。由于它们都在用户态下运行,所以不能物理的访问 I/O 端口空间,也不能直接发出 I/O 命令。相反,为了能够对 I/O 设备编程,驱动器构建一个结构,指明哪个参数值写到哪个 I/O 端口,并声称一个内核调用,这样就完成了一次调用过程。
位于用户态的驱动程序上面是服务器
层,包含有服务器,它们完成操作系统的多数工作。由一个或多个文件服务器管理着文件系统,进程管理器创建、销毁和管理进程。服务器中有一个特殊的服务器称为 再生服务器(reincarnation server)
,它的任务就是检查服务器和驱动程序的功能是否正确,一旦检查出来错误,它就会补上去,无需用户干预。这种方式使得系统具有可恢复性,并具有较高的可靠性。
微内核中的内核还具有一种 机制
与 策略
分离的思想。比如系统调度,一个比较简单的调度算法是,对每个进程赋予一个优先级,并让内核执行具有最高优先级的进程。这里,内核机制就是寻找最高的优先级进程并运行。而策略(赋予进程优先级)可以在用户态中的进程完成。在这种模式中,策略和机制是分离的,从而使内核变得更小。
客户-服务器模式
微内核思想的策略是把进程划分为两类:服务器
,每个服务器用来提供服务;客户端
,使用这些服务。这个模式就是所谓的 客户-服务器
模式。
客户-服务器模式会有两种载体,一种情况是一台计算机既是客户又是服务器,在这种方式下,操作系统会有某种优化;但是普遍情况下是客户端和服务器在不同的机器上,它们通过局域网或广域网连接。
客户通过发送消息与服务器通信,客户端并不需要知道这些消息是在本地机器上处理,还是通过网络被送到远程机器上处理。对于客户端而言,这两种情形是一样的:都是发送请求并得到回应。
越来越多的系统,包括家里的 PC,都成为客户端,而在某地运行的大型机器则成为服务器。许多 web 就是以这种方式运行的。一台 PC 向某个服务器请求一个 Web 页面,服务器把 Web 页面返回给客户端,这就是典型的客服-服务器模式