返回介绍

4.4 进程间通信

发布于 2023-05-19 13:36:37 字数 23123 浏览 0 评论 0 收藏 0

在有限的时间内处理大量的信息,其基本手段就是“分割统治”。也就是说,关键是如何分割大量的数据并进行并行处理。在并行处理中,充分利用多核(一台电脑具备多个 CPU)和分布式环境(用多台计算机进行处理)就显得非常重要。

进程与线程

并行处理的单位,大体上可以分为进程和线程两种(表 1)。

表1 处理的单位和同时运行的特征

处理的单位

内存空间共享

利用多核

线程

因实现不同而不同

进程

进程指的是正在运行的程序。各进程是相互独立的,用一般的方法,是无法看到和改变其他进程的内容和状态的。在 Linux 等 UNIX 系操作系统中,进程也无法中途保存状态或转移到另一台计算机上。即便存在让这种操作成为可能的操作系统,也只是停留在研究阶段而已,目前并没有民用化的迹象。

另一方面,多个线程可以在同一个进程中运行,线程间也可以相互合作。所属于同一个进程的各线程,其内存空间是共享的,因此,多个线程可以访问相同的数据。这是一个优点,但同时也是一个缺点。

说它是优点,是因为可以避免数据传输所带来的开销。在各进程之间,内存是无法共享的,因此进程间通信就需要对数据进行拷贝,而在线程之间进行数据共享,就不需要进行数据的传输。

而这种方式的缺点,就是由于多个线程会访问相同的数据,因此容易产生冲突。例如引用了更新到一半的数据,或者对相同的数据同时进行更新导致数据损坏等,在线程编程中,由于操作时机所导致的棘手 bug 是肯定会遇到的。

虽然灵活使用线程是很重要的,但总归线程的使用范围是在一台计算机中,而大规模的数据仅靠一台计算机是无法处理的。在这一节中,我们主要来介绍一下多台计算机环境中的进程间通信。

同一台计算机上的进程间通信

首先,我们来看同一台计算机上的进程间通信。正如我们在 4-3 中讲过的 HashFold 的实现,在同一台计算机上充分利用多个进程可以带来一定的好处。尤其是在现在的 Ruby 实现中,由于技术上的障碍使得靠线程来利用多核变得很困难(JRuby 除外),因此对进程的活用也就变得愈发重要了。

在 Linux 等 UNIX 系操作系统中,同一台计算机上进行进程间通信的手段有以下几种:

· 管道(pipe)

· 消息(message)

· 信号量(semaphore)

· 共享内存

· TCP 套接字

· UDP 套接字

· UNIX 域套接字

我们从上到下依次解释一下。管道是通过 pipe 系统调用创建一对文件描述符来进行通信的方式。所谓文件描述符,就是表示输入输出对象的一种识别符,在 Ruby 中对应了 IO 对象。当数据从某个 pipe 写入时,可以从另一端的 pipe 读出。事先将管道准备好,然后用“fork”系统调用创建子进程,这样就可以实现进程间通信了。

消息、信号量和共享内存都是 UNIX 的 System V(5) 版本中加入的进程间通信 API。其中消息用于数据通信,信号量用于互斥锁,共享内存用于在进程间共享内存状态。它们结合起来被称为 sysvipc。

不过,上述这些手段都不是很流行。例如管道的优点在于非父子关系的进程之间也可以实现通信,但是当不再使用时必须显式销毁,否则就会一直占用操作系统资源。说实话这并不是一个易用的 API,而关于它的使用信息又很少,于是就让人更加不想去用了,真是一个恶性循环。

套接字(socket)指的是进程间通信的一种通道。它原本是 4.2BSD 中包含的一个功能,但除了 UNIX 系操作系统之外,包括 Windows 在内的各种其他操作系统都提供了这样的功能。

套接字根据通信对象的指定方法以及通信的性质可以分为不同的种类,其中主要使用的包括 TCP 套接字、UDP 套接字和 UNIX 域套接字三种。它们的性质如表 2 所示。

表2 套接字的分类与特征

套接字种类

连接目标指定方法

数据分隔

可靠性

TCP套接字

主机地址+端口号

不保存

UDP套接字

主机地址+端口号

保存

UNIX 域套接字

路径

保存

使用套接字进行通信,需要在事先设定好的连接目标处,通过双方套接字的相互连接创建一个通道。这个连接目标的指定方法因套接字种类而异,在使用最多的 TCP 套接字和 UDP 套接字中,是通过主机地址(主机名或者 IP 地址)和端口号(1 到 65535 之间的整数)的组合来指定的。

位于网络中的每台计算机,都拥有一个被称为 IP 地址的识别码(IPv4 是 4 字节的序列,IPv6 是 16 字节的序列)。例如在 IPv4 中,自己正在使用的电脑所对应的 IP 地址为“127.0.0.1”1。在开始通信时,通过指定对方计算机的 IP 地址,就相当于决定了要和哪台计算机进行通信。

1 IPv6 中本机地址表示为“::1”,关于 IPv6 的细节在本书中就不再赘述了。(原书注)

IP 地址是一串数字,非常难记,因此每台计算机都还有一个属于自己的“主机名”。在这里就不讲述或多细节了,不过简单来说,通过 DNS(Domain Name System,域名系统)这一机制就可以由主机名来获得 IP 地址了。

另一方面,UNIX 域套接字则是使用和文件一样的路径来指定连接目标。在服务器一端创建监听用的 UNIX 域套接字时,需要指定一个路径,而结果就是将 UNIX 域套接字创建在这个指定的路径中。

以路径作为连接目标,就意味着 UNIX 域套接字只能用于同一台计算机上的进程间通信。不过,UNIX 域套接字还具备一些特殊的功能,它不仅可以传输一般的字节流,还可以传输文件描述符。

TCP 套接字被称为流套接字(stream socket),写入的数据只能作为单纯的字节流来对待,因此无法保存每次写入的数据长度信息。

相对地,UDP 套接字和 UNIX 流套接字中,写入的数据是作为一个包(数据传输的单位)来发送的,因此可以获取每次写入的数据长度。不过,当数据过长时,数据包会根据各操作系统所设定的最大长度进行分割。

对于 UDP 套接字,有一点需要注意,那就是基于 UDP 套接字的通信不具备可靠性。所谓没有可靠性,就是说在通信过程中可能会发生数据到达顺序颠倒,最坏的情况下,还可能发生数据在传输过程中丢失的情况。

TCP/IP 协议

利用网络进行通信的协议(protocol)迄今为止已经出现了很多种,但其中一些因为各种原因已经被淘汰了,现在依然幸存下来的就是一种叫做 TCP/IP 的协议。TCP 套接字就是“用 TCP 协议进行通信的套接字”的意思。

TCP 是 Transmission Control Protocal(传输控制协议)的缩写。TCP 是负责错误修恢复、数据再发送、流量控制等行为的高层协议,它是在一种更低层级的 IP 协议(即 Internet Protocol)的基础之上实现的2

2 网络通信协议从低到高共分为 5 层(分别对应 OSI 通信模型中的第 3 ~ 7 层),其中 TCP 和 UDP 都属于较高的“传输层”(OSI 第 6 层),IP 属于更低一级的“网络层”(OSI 第 5 层),而更常见的 HTTP、FTP 等则属于最高的“应用层”(OSI 第 7 层)。

UDP 则是 User Datagram Protocol(用户数据报协议)的缩写。UDP 实际上是在 IP 的基础上穿了一件薄薄的马甲,和 TCP 相比,它有以下这些不同点。

1. 保存通信数据长度

在 TCP 中,发送的数据是作为字节流来处理的。虽然在实际的通信过程中,数据流会被分割为一定大小的数据包,但在 TCP 层上这些包是连接在一起的,无法按照包为单位来查看数据。

相对地,通过 UDP 发送的数据会直接以数据包为单位进行发送,作为发送单位的数据包长度会一直保存到数据接收方。不过,如果包的长度超过操作系统所规定的最大长度(一般为 9KB 左右)就会被分割开,因此也无法保证总是能获取原始的数据长度。

2. 没有纠错机制

要发送的数据在经过网络到达接收方的过程中,可能会发生一些状况,比如数据包的顺序发生了调换,最坏的情况下甚至发生整个数据包丢失。在 TCP 中,每个数据包都会被赋予一个编号,如果包顺序调换,或者本来应该收到的包没有收到,接收方会通知发送方“某个编号的包没有收到”,并请求发送方重新发送该包,这样就可以保证数据不会发生遗漏。

此外,还可以在网络繁忙的时候,对一次发送数据包的大小和数量进行调节,以避免网络发生阻塞。

相对地,UDP 则没有这些机制,像“顺序调换了”、“发送的数据没收到”这样的情况,必须自己来应付。

3. 不需要连接

在 TCP 中,通信对象是固定的,因此,如果要和多个对象进行通信,则需要对每个对象分别使用不同的套接字。

相对地,UDP 则是使用 sendto 系统调用来显式指定发送目标,因此每次发送数据时可以发送给不同的目标。在接收数据时,也可以使用 recvfrom 系统调用,一并获取到发送方的信息。虽然 UDP 不需要进行连接,但在需要的情况下,也可以进行显式的连接来固定通信对象。

4. 高速

由于 TCP 可以进行复杂的控制,因此使用起来比较方便。但是,由于需要处理的工作更多,其实时性便打了折扣。

UDP 由于处理工作非常少,因而能够发挥 IP 协议本身的性能。在一些实时性大于可靠性的网络应用程序中,很多是出于性能上的考虑而选择了 UDP。

例如,在音频流的传输中,即便数据发生丢失也只不过是造成一些音质损失(例如产生一些杂音)而已。相比之下,维持较低的延迟则显得更加重要。在这样的案例中,比较适合采用 UDP 协议来进行通信。

用 C 语言进行套接字编程

在套接字的使用上,已经有了用系统调用构建的 C 语言 API。通过 C 语言可以访问的套接字相关系统调用如表 3 所示。TCP 套接字的使用方法和步骤,以及无连接型 UDP 的步骤如图 1 所示。

表3 套接字相关系统调用

系统调用

功 能

accept(fd, addr, len)

接受连接并返回一个新的套接字

bind(fd, addr, len)

对服务器端套接字命名

connect(fd, addr, len)

套接字连接

getsockopt(fd,level,optname,optval,optlen)

获取套接字选项

listen(fd, n)

设置连接队列(固定用法)

recv(fd, data, len, flags)

接收数据(可指定flags)

recvfrom(fd, data, len, flags, addr, alen)

包含发送方信息的数据接收

send(fd, data, len, flags)

发送数据(可指定flags)

sendto(fd, data, len, flags, addr, alen)

指定接收方的数据发送

setsockopt(fd,level,optname,optval,optlen)

设置套接字选项

socket(domain, type, protocol)

创建套接字

图 1 连接型 TCP 套接字和无连接型 UDP 套接字的使用方法

图 2 是一个使用套接字相关系统调用进行套接字通信的客户端程序。这个程序访问本机(localhost)的 13 号端口,将来自该端口的数据直接输出至标准输出设备。

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int main()
 {
    int sock;
     struct sockaddr_in addr;
     struct hostent *host;
     char buf[128];
     int n;

指   sock = socket(PF_INET, SOCK_STREAM, 0); ←---socket系统调用
定 ┌addr.sin_family = AF_INET;
连 ┤host = gethostbyname("localhost");
接 │memcpy(&addr.sin_addr, host->h_addr, sizeof(addr.sin_addr));
目 └addr.sin_port = htons(13); /* daytime service */
标   connect(sock, (struct sockaddr*)&addr, sizeof(addr));
     n = read(sock, buf, sizeof(buf));
     buf[n] = '\0';
     fputs(buf, stdout);
}
图 2 用 C 语言编写的网络客户端

13 号端口是返回当前时间的“daytime”服务端口号码,所返回的当前时间是一个字符串。最近的操作系统倾向于关闭所有不必要的服务,因此 daytime 服务可能不可用。如果你电脑上的 daytime 服务正常工作的话,运行这个程序将显示类似下面这样的字符串:

Sat Oct 10 15:26:28 2009
用 C 语言来编写程序,仅仅是打开套接字并读取数据这么简单的操作,也需要十分繁琐的代码。

那我们就来看一看程序的内容吧。首先通过第 15 行的 socket 系统调用创建套接字。其中参数的意思是使用基于 IP 协议的流连接(TCP)(表 4)。第 3 个参数“0”表示使用指定通信域的“最普通”的协议,一般情况下设为 0 就可以了。

表4 socket系统调用的参数

协议类型

说 明

PF_INET

IPv4 协议

PF_INET6

IPv6 协议

PF_APPLETALK

ApleTalk 协议

PF_IPX

IPX 协议

通信类型

SOCK_STREAM

字节流套接字

SOCK_DGRAM

数据报套接字

第 16 ~ 19 行用于指定连接目标。sockaddr_in 结构体中存放了连接目标的地址类型(类别)、主机地址和端口号。

需要注意的是第 19 行中指定端口号的 htons()。htons() 函数的功能是将 16 位整数转换为网络字节序(network byte order),即各字节的发送顺序。由于套接字连接目标的指定全部需要通过网络字节序来进行,如果忘记用这个函数进行转换的话就无法正确连接。

服务器端程序则更加复杂,因此在这里不再赘述,不过大家应该对用 C 语言处理网络连接有一个大概的了解了吧。

用 Ruby 进行套接字编程

以系统调用为基础的 C 语言套接字编程相当麻烦。那么,Ruby 怎么样呢?图 3 师和图 2 的 C 语言程序拥有相同功能的 Ruby 程序。

require 'socket'
print TCPSocket.open("localhost", "daytime").read
图 3 Ruby 编写的网络客户端

值得注意的是,除了库引用声明“require”那一行之外,实质上只需要一行代码就完成了套接字连接和通信。和 C 语言相比,Ruby 的优势相当明显。

用套接字进行网络编程是 Ruby 最擅长的领域之一,原因如下。

1. 瓶颈

在程序开发中,对于是否采用 Ruby 这样的脚本语言,犹豫不决的理由之一就是运行性能。

在比较简单的工作中,如果由于解释器的实现方式导致性能下降,其影响是相当大的。如果用一个简单的循环来测试程序性能,那么 Ruby 程序速度可能只有 C 语言程序的十分之一甚至百分之一。光从这一点来看,大家不禁要担心,这么慢到底能不能用呢?

不过,程序的运行时间其实大部分都消耗在 C 语言编写的类库内部,对于拥有一定规模的实用型程序来说,差距并没有那么大。

更进一步说,对于以网络通信为主体的程序来说,其瓶颈几乎都在于通信部分。和本地访问数据相比,网络通信的速度是非常慢的。既然瓶颈在于通信本身,那么其他部分即便运行速度再快,也和整体的性能关系不大了。

2. 高级 API

C 语言中可以使用的套接字 API 包括结构体和多个系统调用,非常复杂。

在图 2 的 C 语言程序中,为了指定连接目标,必须初始化 sockaddr_in 结构体,非常麻烦。相对地,在 Ruby 中由于 TCPSocket 类提供了比较高级的 API,因此程序可以变得更加简洁易懂。如果想和 C 语言一样使用套接字的全部功能,通过支持直接访问系统调用的 Socket 类就可以实现了。

Ruby 的套接字功能

那么,我们来详细看看 Ruby 的套接字功能吧。在 Ruby 中,套接字功能是由“socket”库来提供的。要使用 socket 库的功能,需要在 Ruby 程序中通过下面的方式来加载这个库:

require 'socket'
socket 库中提供的类包括 BasicSocket、IPSocket、TCPSocket、TCPServer、UDPSocket、UNIXSocket、UNIXServer 和 Socket(图 4)。在客户端编程上,恐怕其中用得最多的应该是 TCPSocket,而在服务器端则是 TCPServer。

IO
│
└─BasicSocket
   │
   ├─IPSocket
   │ │
   │ ├─TCPSocket
   │ │ │
   │ │ └─TCPServer
   │ └─UDPSocket
   │
   ├─ UNIXSocket
   │ │
   │ └─UNIXServer
   └─Socket
图 4 套接字相关的类

其中 Socket 类可以调用操作系统中套接字接口的所有功能,但由于是直接访问操作系统的接口,因此程序就会变得比较复杂。Ruby 的套接字属于 IO 的子类,因此对套接字也可以进行普通的输入输出操作,这一点非常方便。

BasicSocket 是 IO 的直接子类,同时也是其他所有套接字类的超类。BasicSocket 是一个抽象类,并不能创建实例。BasicSocket 类中的方法如表 5 所示。

表5 BasicSocket类的方法

实例方法

说 明

close_read

关闭读取

close_write

关闭写入

getpeername

连接目标套接字信息

getsockname

自己的套接字信息

getsockopt(opt)

获取套接字选项

recv(len[,flag])

数据接收

send(str[,flag])

数据发送

setsockopt(opt,val)

设置套接字选项

shutdown([how])

结束套接字通信

IPSocket 是 BasicSocket 的子类,也是 TCPSocket、UDPSocket 的超类,它包含了这两个类共通的一些功能,也是一个抽象类。IPSocket 类中的方法如表 6 所示。

表6 IPSocket类的方法

实例方法

说 明

addr

自己的套接字信息

peeraddr

连接目标套接字信息

recvfrom(len[,flag])

数据接收

TCPSocket 是连接型套接字,即和通信对方进行连接并进行连续数据传输的套接字。TCPSocket 是一个具体类(可以直接创建实例的类)。创建实例需要使用 new 方法,new 方法的调用方式为 new (host, port),可以完成套接字的创建和连接操作。

TCPServer 是 TCPSocket 的服务器版本,通过这些类可以大大简化服务器端的套接字处理。当为 new 方法指定两个参数时,可以限定只接受来自第一个参数所指定的主机的连接(表 7)。

表7 TCPServer类的方法

类 方 法

说 明

new([host,]port)

套接字的创建和连接

实例方法

accept

接受连接

listen(n)

设置连接队列

UDPSocket 是对 UDP 型套接字提供支持的类。UDP 型套接字是无连接型套接字,其特征是可以保存每次写入的数据长度。UDPSocket 类中的方法如表 8 所示。

表8 UDPSocket类的方法

类 方 法

说 明

new([socktype])

创建套接字

实例方法

bind(host,port)

为套接字命名

connect(host, port)

套接字连接

send(data[,flags,host, port])

发送数据

UNIXSocket 是用于 UNIX 域套接字的类。 UNIX 域套接字是一种用于同一台计算机上进程间通信的手段,在通信目标的指定上采用“文件路径”的方式,其他方面和 TCPSocket 相同,也是需要连接并进行流式输入输出。UNIXSocket 类中的方法如表 9 所示。

表9 UNIXSocket类的方法

类 方 法

说 明

new(path)

创建套接字

socketpair

创建套接字对

实例方法

path

套接字路径

addr

自己的套接字信息

peeraddr

连接目标套接字信息

recvfrom(len[,flag])

数据接收

send_io(io)

发送文件描述符

recv_io([klass,mode])

接收文件描述符

send_io 和 recv_io 这两个方法是 UNIX 域套接字的独门功夫。使用这两个方法,可以通过 UNIX 域套接字将文件描述符传递给其他进程。一般来说,在进程间传递文件描述符,只能通过具有父子关系的进程间共享这一种方式,但使用 UNIX 域套接字就可以在非父子关系的进程间实现文件描述符的传递了。

UNIXServer 是 UNIXSocket 的服务器版本。和 TCPServer 一样,用于简化套接字服务器的实现。其中所补充的方法也和 TCPServer 相同。

最后要介绍的 Socket 类是一个底层套接字接口。Socket 类所拥有的方法对应着 C 语言级别的全部套接字 API,因此,只要使用 Socket 类,就可以和 C 语言一样进行同样细化的程序设计,但由于这样实在太繁琐所以实际上很少用到。Socket 类中的方法如表 10 所示,套接字相关各类的功能一览如表 11 所示。

表10 Socket类的方法

类 方 法

说 明

new(domain,type,protocol)

创建套接字

socketpair(domain,type,protocol)

创建套接字对

gethostname

获取主机名

gethostbyname(hostname)

获取主机信息

gethostbyaddr(addr, type)

获取主机信息

getservbyname(name[,proto])

获取服务信息

getaddrinfo(host,service[,family,type,protocol])

获取地址信息

getnameinfo(addr[,flags])

获取地址信息

pack_sockaddr_in(host,port)

创建地址结构体

unpack_sockaddr_in(addr)

解包地址结构体

实例方法

accept

等待连接

bind(addr)

为套接字命名

connect(host, port)

连接套接字

listen(n)

设置连接队列

recvfrom(len[,flag])

数据接收

表11 套接字相关的类

BasicSocket

所有套接字类的超类(抽象类)

IPSocket

执行IP通信的抽象类

TCPSocket

连接型流套接字

TCPServer

TCPSocket 用的服务器套接字

UDPSocket

无连接型数据报套接字

UNIXSocket

用于同一主机内进程间通信的套接字

UNIXServer

UNIXSocket 用的服务器套接字

Socket

可使用 Socket 系统调用所有功能的类

用 Ruby 实现网络服务器

我们已经通过 C、Ruby 两种语言介绍了客户端套接字编程的例子,下面我们来看看服务器端的设计。刚才那个访问 daytime 服务的程序可能有很多人都无法成功运行,于是我们来编写一个和 daytime 服务器拥有相同功能的服务器程序。原来的 daytime 服务端口只能由 root 账号使用(1024 号以内的端口都需要 root 权限),因此我们将连接端口设置为 12345(图 5)。

require 'socket'
s = TCPServer.new(12345)
loop {
  cl = s.accept
  cl.print Time.now.strftime("%c")
  cl.close
}
图 5 Ruby 编写的网络服务器

这样就完成了。网络服务器可能给人的印象很庞大,其实却出人意料地简单。这也要归功于 TCPServer 类所提供的高级 API。

先运行这个程序,然后从另一个终端窗口中运行刚才的客户端程序(C 语言版见图 2,Ruby 版见图 3),运行之前别忘了将 daytime 的部分替换成“12345”。运行结果如果显示出类似下面这样的一个时间就表示成功了。

Mon Jun 12 18:52:38 2006
下面我们来简单讲解一下图 5 的这个程序。第 2 行我们创建了一个 TCPServer,参数是用于连接的端口号,仅仅如此我们就完成了 TCP 服务的建立。

第 3 行开始是主循环。第 4 行中对于 TCPServer 套接字调用 accept 方法。accept 方法会等待来自客户端的连接,如果有连接请求则返回与客户端建立连接的新套接字,我们在这里将新套接字赋值给变量 cl。客户端套接字是 TCPSocket 的对象,即 IO 的子类,因此它也是一个可以执行一般输入输出操作的对象。

第 5 行 print 当前时间,daytime 服务的处理就这么多了。处理完成后将客户端套接字 close 掉,然后调用 accept 等待下一个连接。

图 5 的程序会对请求逐一进行处理。对于像 daytime 这样仅仅是返回一个时间的服务也许还好,如果是更加复杂的处理的话,这样可就不行了。如果 Web 服务器在完成前一个处理之前无法接受下一个请求,其处理性能就会下降到无法容忍的地步。在这样的情况下,使用线程或进程进行并行处理是比较常见的做法。使用线程进行并行化的程序如图 6 所示。

require 'socket'
s = TCPServer.new(12345)
loop {
  Thread.start(s.accept) { |cl|
    cl.print Time.now.strftime("%c")
    cl.close
  }
}
图 6 用线程实现并行处理的程序

正如大家所见,用 Ruby 进行网络编程是非常容易的。有很多人认为提到 Ruby 就必然要提到 Web 编程,或许不如说,只有网络编程才能发挥 Ruby 真正的价值吧。

小结

利用套接字,我们就可以通过网络与地球另一端的计算机进行通信。不过,套接字所能传输的数据只是字节序列而已,如果要传输文本以外的数据,在传输前需要将数据转换为字节序列。

这种转换一般称为序列化(serialization)或者封送(marshaling)。在分布式编程环境中,由于会产生大量数据的传输,因此序列化通常会成为左右整体性能的一个重要因素。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文