61
第2章 套接字基础 本章如何访问 TCP/IP 这个问题出出协议软件接口概念。协议软件接口承担网络应用 程序操作系统中 TCP/IP 协议实现之间的桥梁作用,接字是一种广泛使用的协议软件接口。本 章主要介绍接字(Socket)的基本概念,并重点介绍 Windows 环境下的接字实现 Windows Sockets2.1 TCP/IP 协议软件接口 TCP/IP 协议为网络通信设计提供了一系具备各类能的传输服务,网络应用程序的设计者 可以据实际需要灵活选择这些服务,在分于网络不同位置的网络应用程序之间实现数据交互。 操作系统层面来,操作系统内核集成了对 TCP/IP 的具体实现,具有用协议应用能网络应用程序层面来,各类涉及网络通信的应用都通过系统中的协议实现成数据交互过程, 应用程序在用户行。 那么对于两个不同层次上的实现,应用程序如何访问操作系统内核中协 议实现的具体功能呢?其实是通过协议软件接口这个桥梁,如图 2-1 所示,该图示了网络应用 程序协议实现之间的关系。 2-1 网络应用程序与协议实现的关系

第2章 - tup.com.cn

  • Upload
    others

  • View
    4

  • Download
    0

Embed Size (px)

Citation preview

Page 1: 第2章 - tup.com.cn

第 2 章

套接字基础

本章从如何访问 TCP/IP这个问题出发,引出协议软件接口概念。协议软件接口承担网络应用

程序与操作系统中 TCP/IP协议实现之间的桥梁作用,套接字是一种广泛使用的协议软件接口。本

章主要介绍套接字(Socket)的基本概念,并重点介绍 Windows 环境下的套接字实现 Windows

Sockets。

2.1 TCP/IP协议软件接口

TCP/IP 协议为网络通信设计提供了一系列具备各类能力的传输服务,网络应用程序的设计者

可以根据实际需要灵活选择这些服务,在分布于网络不同位置的网络应用程序之间实现数据交互。

从操作系统层面来看,操作系统内核集成了对 TCP/IP的具体实现,具有常用协议应用能力;

从网络应用程序层面来看,各类涉及网络通信的应用都通过系统中的协议实现完成数据交互过程,

应用程序在用户空间执行。那么对于两个不同层次上的实现,应用程序如何访问操作系统内核中协

议实现的具体功能呢?其实就是通过协议软件接口这个桥梁,如图 2-1所示,该图展示了网络应用

程序与协议实现之间的关系。

图 2-1 网络应用程序与协议实现的关系

Page 2: 第2章 - tup.com.cn

24 | 网络程序设计应用教程

协议软件接口承担应用程序与操作系统协议实现之间的桥梁作用,它封装了协议实现的基本功

能,开放系统调用接口让操作简化,使得网络应用程序可以用系统调用的方式方便地使用协议实现

提供的数据传输功能。

目前常用的协议软件接口如下所示。

(1)Berkeley Socket: 加州大学伯克利分校为 Berkeley UNIX操作系统定义的套接字接口。

(2)Transport Layer Interface: AT&T为其 System V定义的传输层接口。

(3)Windows Sockets: Windows系统下的套接字接口。

这里要特别注意协议软件接口的“接口”功能,即协议软件接口为网络应用程序和操作系统协

议栈建立了调用的关联,其功能主要体现在关联的能力上,如创建用于关联的标识,为网络通信操

作分配资源、复制数据、读取信息等,而真实的网络通信功能是由协议栈具体完成的,程序逻辑由

网络应用程序来控制。

2.2 套接字

在 TCP/IP 网络环境中,通常使用套接字(Socket)来建立网络连接,从而实现主机之间的数

据传输。

2.2.1 套接字编程接口的起源与发展

20世纪 70年代,美国国防部高级研究计划署(Defense Advanced Research Projects Agency,

简称 DARPA)将 TCP/IP 的软件提供给加利福尼亚大学伯克利分校,伯克利分校开发了一个集成

TCP/IP协议的 UNIX操作系统,称为 BSD(Berkeley Software Distribution)UNIX操作系统。BSD

UNIX也实现了 TCP/IP应用程序接口(API)),称为 Socket接口,也称为 Berkeley Sockets。后来

许多计算机供应商将 BSD系统移植到它们的硬件上,并将其作为商业操作系统产品的基础组件广

泛地应用于各种计算机上。BSD UNIX系统的广泛应用让大多数人接受了 Socket接口,后来的许

多操作系统也选择了支持 Socket接口,如Windows操作系统、其他各种 UNIX系统(如 Sun公司

的 Solaris、IBM 公司的 AIX等)以及各种 Linux系统都实现了 BSD UNIX Socket接口,并结合自

己系统的特点将其扩展。各种编程语言也纷纷支持 Socket接口,使得 Socket接口成为 TCP/IP网络

最为通用的 API,也是在 Internet上进行应用开发最为通用的 API,因此就使得 Socket接口成为工

业界事实上的标准。

2.2.2 套接字的抽象概念

Socket直译是凹槽、插座的意思,它形象地把网络操作比喻成生活中的电线插座这种简单且具

有连通能力的设备。

Page 3: 第2章 - tup.com.cn

第 2 章 套接字基础 | 25

套接字(Socket)是支持 TCP/IP 网络通信的基本操作单元,可以看作是一个接口,该接口在

操作系统控制下帮助本地的网络应用程序与其他(远程)应用进程相互发送和接收数据,也就是说,

通过 Socket可为客户端和服务器建立一个双向的连接管道。

作为连接网络应用程序和协议实现的桥梁,套接字在应用程序中创建,并通过绑定应用程序所

在的 IP地址和端口号与系统里的协议实现建立关系。此后,网络应用程序送给 Socket的数据,由

Socket交给协议实现由网络发送出去,而计算机从网络上收到与该 Socket绑定 IP地址和端口号相

关的数据后,由系统协议实现交给 Socket,网络应用程序便可从该 Socket 中获得接收到的数据。

网络应用程序就是这样通过 Socket进行数据的网络发送与接收的。

套接字接口不直接使用协议类型来标识通信时的协议,而是采用一种更灵活的方式—协议簇

+套接字类型,这样便于网络应用程序在多协议簇的同类套接字程序之间相互移植。

在套接字通信中,常用的协议簇有如下几种。

(1)PF_INET:IPv4协议簇。

(2)PF_INET6:IPv6协议簇。

(3)PF_IPX:IPX/SPX协议簇。

(4)PF_NETBIOS:NetBIOS协议簇。

在套接字通信中,常用套接字类型包括 3类:

(1)流式套接字(SOCK_STREAM)。流式套接字用于提供面向连接、可靠的数据传输服务,

该服务将保证数据能够实现无差错、无重复发送,并按顺序接收。数据传输可以是双向的字节流。

(2)数据报套接字(SOCK_DGRAM)。数据报套接字用于提供无连接的数据传输服务。该

服务并不能保证数据传输的可靠性,数据有可能在传输过程中丢失或重复,且无法保证顺序地接收

到数据。由于数据报套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在

程序中做相应的处理。

(3)原始套接字(SOCK_RAW)。当使用前两种套接字无法完成数据收发任务时,原始套接

字提供了更加灵活的数据访问接口,使用它可以在网络层上对 Socket 进行编程,发送和接收网络

层上的原始数据包。

2.2.3 套接字接口的特点和内容

套接字接口的主要特点如下:

(1)一个程序可以同时使用多个套接字,不同套接字完成不同的传输任务。

(2)多个应用程序可以同时使用同一个套接字,不过这种情况并不常见。

(3)每个套接字都有一个关联的本地 TCP或 UDP端口,端口标识了主机上的套接字,进而

标识了主机上的特定应用程序。

(4)TCP和 UDP的端口号是独立使用的,但有时一个 TCP的端口号可能关联多个套接字,

此时不能仅仅用端口来标识套接字,但 TCP还是有能力区分开关联在一个端口上的多个套接字。

Page 4: 第2章 - tup.com.cn

26 | 网络程序设计应用教程

与文件描述符类似,套接字在具体实现时表现为系统中的一个整数类型的标识符,用于标识操

作系统为该套接字分配的套接字结构,该结构保存了一个通信端点的数据集合。这里的“数据结构”

是一个抽象意义的结构,意指套接字抽象层和 TCP/IP协议实现中与套接字描述符相关的所有数据

结构和变量,主要包括以下几部分:

(1)套接字创建时所使用的通信协议类型。

(2)与套接字关联的本地和远程的端点(IP 地址+端口号)。这些端点信息将在套接字操作

过程中由不同的函数来逐步确定。

(3)一个等待向网络应用程序传递数据的接收队列和一个等待向协议栈传输数据的发送队列。

(4)对于流式套接字,还包含与打开和关闭 TCP连接相关的协议状态信息。

2.3 Windows套接字

Windows套接字是Windows环境下的网络编程接口,它最初起源于UNIX环境下的BSD Socket,

逐步发展成一个与网络协议无关的编程接口。

2.3.1 Windows Sockets规范

20世纪 90年代初,由Microsoft联合 Sun Microsystems、FTP Software等几家公司共同制定了

一套Windows系统下的网络编程接口,即Windows Sockets规范。它在 BSD Socket的基础上进行

了扩充,主要是增加了一些异步函数,另外还增加了符合 Windows 消息驱动特性的网络事件异步

选择机制,并包含了一组针对Windows的扩展库函数,以便程序员能够充分地利用Windows消息

驱动机制进行编程。

Windows Sockets 规范是一套开放的、支持多种协议的 Windows 环境下的网络编程接口。在

实际应用中的Windows Sockets规范主要有 1.1版和 2.0版,两者的最重要区别是Windows Sockets

1.1版只支持 TCP/IP协议,而Windows Sockets 2.0版可以支持多协议且具有良好的向后兼容性。

目前,Windows下的 Internet软件都是基于 Windows Sockets开发的。

WinSock 是 Windows 套接字的简称。WinSock 是一套标准 API(Application Programming

Interface,应用程序编程接口),主要用于网络中的数据通信,它使得两个或者多个应用程序(或

进程)在同一台机器上或通过网络相互通信。有一点必须明白:WinSock是一种网络编程接口,而

不是协议。使用WinSock编程接口,应用程序可通过普通网络协议如 TCP/IP(Transmission Control

Protocol/Internet Protocol,传输控制协议/网际协议)或 IPX(Internet Packet Exchange,Internet数

据包交换)协议建立通信。既然 WinSock接口是从 BSD Socket扩展而来,因此它从 BSD Socket

中继承了大量的特性。

Page 5: 第2章 - tup.com.cn

第 2 章 套接字基础 | 27

2.3.2 WinSock头文件和库文件

WinSock主要有两个版本,即WinSock 1.1和WinSock 2,两者都能在除Windows CE之外的

所有Windows平台上运行,Windows CE只支持WinSock 1.1。使用WinSock开发网络应用程序时,

把相应的 WinSock 头文件和库文件包含在应用程序中,另外运行时可能还需要链接动态库文件。

WinSock 1.1和WinSock 2这两个版本的这些文件是有区别的,表 2-1列出了这两个版本的相关文件。

表 2-1 WinSock 相关文件

版本 头文件 静态链接库 动态链接库

WinSock 1.1 WinSock.h WinSock.lib WinSock.dll

WinSock 2 WinSock2.h ws2_32.lib ws2_32.dll

2.3.3 WinSock API

WinSock API是Windows提供给基于套接字的网络应用程序开发的接口,一方面继承了 BSD

Socket的基本函数定义,另一方面在WinSock 1.1和WinSock 2这两个版本上进一步扩展了Windows

Sockets特有的功能。表 2-2列出了继承 BSD Socket的主要基本函数的名称及功能。表 2-3列出了

WinSock扩展的主要库函数的名称及功能,具体函数的使用细节将在之后的章节中深入介绍。更详

细的WinSock扩展库函数内容参见微软公司的MSDN(Microsoft Developer Network)。

表 2-2 WinSock 继承的 BSD Socket 主要基本函数

函数名称 功能描述

accept() 在一个套接字上接受连接请求

bind() 将本地地址与套接字绑定

closesocket() 关闭现有的套接字

connect() 在指定的套接字与远端计算机之间建立连接

gethostbyaddr() 根据网络地址获得计算机信息

gethostbyname() 根据计算机名获得计算机信息

getpeername() 获得与套接字相连的远端地址信息

getsockname() 获得与套接字相连的本地地址信息

getsockopt() 获得套接字的选项值

htonl() 将 u_long从主机字节顺序转换为 TCP/IP的网络字节顺序

htons() 将 u_short从主机字节顺序转换为 TCP/IP的网络字节顺序

inet_addr() 将 IPv4的点分字符形式地址转换为无符号的 4字节整数形式地址

inet_ntoa() 将 IPv4的无符号 4字节整数形式地址转换为点分字符形式地址

ioctlsocket() 控制套接字的 I/O模式

Page 6: 第2章 - tup.com.cn

28 | 网络程序设计应用教程

(续表)

函数名称 功能描述

listen() 将套接字设置为监听状态

ntohl() 将 u_long从 TCP/IP网络字节顺序转换为主机字节顺序

ntohs() 将 u_short从 TCP/IP网络字节顺序转换为主机字节顺序

recv() 从套接字上接收数据

recvfrom() 从套接字上接收数据报并获得其源地址

select() 确定一个或多个套接字的状态并等待其是否成功

send() 从套接字上发送数据

sendto() 从套接字上向指定目的地址发送数据报

setsockopt() 设置套接字选项值

shutdown() 在套接字上禁用数据的发送或接收

socket() 创建套接字

表 2-3 WinSock 扩展的主要 API

函数名称 功能描述 版本

gethostname() 从本地获得计算机名 1.1

WSAAsyncGetHostByAddr() 根据网络地址异步获得计算机信息 1.1

WSAAsyncGetHostByName() 根据计算机名异步获得计算机信息 1.1

WSAAsyncSelect() 通知套接字有基于Windows消息的网络事件发生 1.1

WSAClean() 结束Windows Sockets DLL的使用 1.1

WSAGetLastError() 获得上次失败操作的错误状态 1.1

WSAStartup() 初始化Windows Sockets DLL的使用 1.1

WSAAccept() 基于条件函数的返回值有条件地接受连接请求 2.0

WSAAddressToString () 将 Sockaddr结构的所有成员转换为易读的字符串 2.0

WSACloseEvent() 关闭已打开的事件对象句柄 2.0

WSAConnect() 与另一个套接字应用程序建立连接,交换连接数据 2.0

WSACreateEvent() 创建事件对象 2.0

WSAEventSelect() 设置与所提供的 FD_XXX网络事件集合相关的事件对象 2.0

WSAIoctl() 控制套接字的 I/O模式 2.0

WSARecv() 从已连接的套接字上接收数据 2.0

WSARecvEx() 从已连接的套接字上接收数据,和 recv()一致 2.0

WSARecvFrom() 从套接字上接收数据报并获得其源地址 2.0

Page 7: 第2章 - tup.com.cn

第 2 章 套接字基础 | 29

(续表)

函数名称 功能描述 版本

WSAResetEvent() 将指定事件对象的状态复位为非信号状态 2.0

WSASend() 从已连接的套接字上发送数据 2.0

WSASendTo() 将数据发送到指定的地址,可以适当使用重叠 I/O 2.0

WSASetEvent() 将指定的事件对象设置为信号状态 2.0

WSASocket() 创建套接字 2.0

WSAStringToAddress() 将数值字符串转换为 Sockaddr结构 2.0

2.3.4 WinSock初始化

每个WinSock应用都必须加载合适的WinSock版本。如果调用一个WinSock函数之前没有加

载 WinSock 库,这个函数就会返回一个 SOCKET_ERROR。错误信息是 WSANOTINITIALISED。

加载WinSock库是通过调用WSAStartup函数实现的。这个函数的定义如下:

int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);

wVersionRequested参数用于指定准备加载的WinSock库的版本。高位字节指定所需WinSock

库的次版本,而低位字节则是主版本。可以使用宏 MAKEWORD(x, y)(其中 x是高位字节,y是

低位字节)更方便地获得 wVersionRequested的正确值。

lpWSAData 多数指向 LPWSADATA 结构的指针,WSAStartup 用与其加载的库版本有关的信

息填充这个结构:

typedef Struct WSAData

{

WORD wVersion;

WORD wHighVersion;

char szDescription[WSADESCRIPTION_LEN +1 ];

char szSystemStatus[WSASYS_STATUS_LEN +1];

unsigned short iMaxSockets;

unsigned short iMaxUdpDq;

char FAR* lpVendorInfo;

} WSADATA, *LPWSADATA;

WSAStartup 将第一个字段 wVersion 设置为将要使用的 WinSock 版本。wHighVersion 参数包

含了现有 WinSock 库的最高版本。szDescription 和 szSystemStatus 这两个字段由特定的 WinSock

来实现和设置,它们并没有实际作用。不要使用 iMaxSockets 和 iMaxUdpDg 这两个字段,它们分

别表示可以同时打开的最大套接字数量和数据报的最大长度。数据报的最大长度和协议相关;可以

同时打开的最大套接字数量不是固定的,它的值在很大程度上取决于可用的物理资源。最后的

lpVendorInfo 是保留字段,用于存储实现 WinSock 的特定供应商的信息。所有的 Windows 平台都

没有使用这个字段。下面代码为具体实现WinSock的初始化:

Page 8: 第2章 - tup.com.cn

30 | 网络程序设计应用教程

WSADATAwsa_data;

//WinSock 2版本,如 WinSock1.1版本则 MAKEWORD(1, 1);

WORDwinsock_ver = MAKEWORD(2, 0);

//初始化 WinSock,返回 0表示初始化成功

intret = WSAStartup(winsock_ver, &wsa_data);

在使用 WinSock 接口编写应用程序之后,应该调用 WSACleanup 函数。这个函数能够使

WinSock释放所有由WinSock分配的资源,并取消这个应用程序挂起的WinSock调用。WSACleanup

函数的定义为:

int WSACleanup (void);

每次调用WSAStartup后都应该调用WSACleanup。

2.3.5 错误检查和处理

要想成功编写 Winsock 应用程序,检查和处理错误是至关重要的,所以这里首先对此进行介

绍。事实上,对 Winsock 函数来说返回错误是很常见的,但是在有些情况下这些错误是无关紧要

的,通信仍可在那个套接字上进行。WinSock调用失败时最常见的返回值是 SOCKET_ERROR(-1)

或 INVALID_SOCKET(0xFFFFFFFF)。本书在详细介绍各个 API 调用时都将指出和各种错误对应

的返回值。如果调用 WinSock 函数时出现了错误,可以用 WSAGetLastError 函数来获得一个整数

代码,这个代码专门用来说明错误。该函数的定义如下:

int WSAGetLastError (void);

发生错误之后调用这个函数,返回的是所发生的错误的整数代码。WSAGetLastError函数返回

的这些错误代码都有已经预先定义的常量值;因 WinSock 版本的不同,这些值的声明可能在

WINSOCK.H 中,也可能在 WINSOCK2.H 中。两个头文件的差别是 WINSOCK2.H 中包含更多针

对 WinSock 2中添加的新 API函数和功能的错误代码。为各种错误代码定义的常量(带有#define

指令)一般都用WSAE开头。

2.3.6 WinSock的地址描述

编写网络应用程序,需要有一套机制来标识通信的双方。在 Internet各个协议层次上存在不同

的寻址方式,当选择不同层次上的网络程序设计时会使用多种地址标识。比如网络中节点(计算机

或路由器)的链路层使用物理地址或MAC地址为唯一标识;计算机或者网络设备的网络层使用 IP

地址来寻址等。

其他协议簇按照其各自的方式定义端点地址。

1. 端点地址

TCP/IP协议定义了端点地址来标识通信端点,它包括一个 IP地址加一个协议端口号。由于套

Page 9: 第2章 - tup.com.cn

第 2 章 套接字基础 | 31

接字适用于多种协议簇,它既没有指明如何定义端点地址,也没有定义一种特定的协议地址格式,

而是改为允许每个协议簇自由定义。为了允许这种自由选择地址表示方式,套接字为每种类型的地

址定义了一个地址族。一个协议簇可以使用一种或多种地址族来定义地址表示方式。常见的地址族

有以下几种。

(1)AF_INET: IPv4地址族。

(2)AF_INET6: IPv6地址族。

(3)AF_IPX: IPX/SPX地址族。

(4)AF_ NETBIOS: NetBIOS地址族。

2. 地址结构

为了适应各种不同地址族对地址的描述,套接字接口定义了一个一般化的格式,可以为所有端

点地址使用。这种一般化的端点地址定义为 sockaddr 结构,它包含了一个 2 字节的地址族标识,

以及一个 14字节的数组可以存储各种地址族定义的地址,其具体形式依赖于选择的地址族,该结

构的定义如下:

struct sockaddr {

u_short sa_family;

char sa_data[14];

};

真实情况是使用套接字的每个地址族不可能用数组形式描述地址,而是会定义适合自己的地址

形式。对于 IPv4常用的地址结构是 sockaddr_in,该结构定义如下:

struct sockaddr_in {

short sin_family;

u_short sin_port;

struct in_addr sin_addr;

char sin_zero[8];

};

其中的 in_addr结构定义如下:

typedef struct in_addr {

union {

struct {

u_char s_b1,s_b2,s_b3,s_b4;

} S_un_b;

struct {

u_short s_w1,s_w2;

} S_un_w;

u_long S_addr;

} S_un;

} IN_ADDR, *PIN_ADDR, FAR *LPIN_ADDR;

Page 10: 第2章 - tup.com.cn

32 | 网络程序设计应用教程

可以看到 sockaddr_in 结构包括 Internet 地址、端口号和地址族字段,为了与 sockaddr 结构的

长度保持一致,增加了 8字节的保留字段。sockaddr_in结构是 sockaddr结构中的数据针对 IPv4的

套接字而定制的,因此我们可以填写 sockaddr_in结构中的字段,然后把它强制类型转换为 sockaddr

后传递给套接字函数,函数将会根据 sa_family字段强制转换回相应的类型。

对于 IPv6常用的 IPv6地址结构是 sockaddr_in6,该结构定义如下:

struct sockaddr_in6 {

short sin6_family;

u_short sin6_port;

u_long sin6_flowinfo;

struct in6_addr sin6_addr;

u_long sin6_scope_id;

};

其中的 in6_addr结构定义如下:

typedef struct in6_addr {

union{

u_char Byte [16] ;

u_short Word [8] ;

} u;

} IN6_ADDR, *PIN6_ADDR, FAR *LPIN6_ADDR;

除了和 sockaddr_in 的相同字段之外,sockaddr_in6 结构还具有一些额外的字段。这些字段用

于 IPv6协议的一些不太常用的功能。

与 sockaddr_in 类似,需要把 sockaddr_in6 强制类型转换为 sockaddr,再把它传递给不同的套

接字函数,之后,函数根据地址族字段来确定参数的实际类型。

3. 地址转换

sockaddr_in结构的 sin_addr字段把 IPv4地址(后面简称 IP地址)作为一个 4字节的变量存储

起来,它是无符号长整数的数据类型。而当用户一般输入和读取 IP 地址时,习惯用点分形式的字

符串来记录地址,如此一来,IP 地址的无符号长整数形式和点分字符形式的转换在程序中经常出

现。

inet_addr是一个很实用的支持函数,它可把点分字符形式的 IP地址转换成无符号长整数形式。

它的定义如下:

unsigned long inet_addr(const char FAR *cp);

其中 cp 指向了以点分字符形式描述的 IP 地址。如果转换成功,这个函数把 IP 地址当作一个

按网络字节顺序排列的无符号长整数返回,否则为 INADDR_NONE。

另一个与之对应的函数 inet_ntoa()将 IP 地址的无符号长整数形式转换为点分字符形式。该函

数的定义如下:

char FAR * inet_ntoa(struct in_addr in);

Page 11: 第2章 - tup.com.cn

第 2 章 套接字基础 | 33

其中 in存储了 in_addr结构体中的 32位二进制网络字节顺序的 IPv4地址。如果转换成功,返

回一个字符指针,指向一块存储着点分形式的 IP地址的静态缓冲区,否则返回 NULL。

为了扩展对 IPv6地址转换的支持,对应于 inet_addr()和 inet_ntoa()函数的功能,WinSock 2分

别提供了 WSAStringToAddress()和 WSAAddressToString()两个函数,以方便 IPv6地址的字符串形

式与二进制形式的互相转换。

2.3.7 创建套接字

WinSock 的 API 是建立在套接字概念基础上的。套接字是网络通信提供给程序的句柄。在

Windows 中,套接字和文件描述符不同,是一个独立的类型,即 WINSOCK2.H 中的 SOCKET 类

型。有两个函数可以用来创建套接字:socket()和 WSASocket()。随后的三章将详细讲述如何创建

每种可用的套接字。socket函数定义如下:

SOCKET socket (int af, int type, int protocol);

第 1 个参数 af 是协议的地址族,比如使用 IPv4 地址族,应将这个字段设为 AF_INET。第 2

个参数 type是协议的套接字类型,如果使用 TCP创建套接字,应将此字段设为 SOCK_STREAM,

而用 UDP 时则应设为 SOCK_DGRAM。第 3 个参数是 protocol,用于在给定地址族和套接字类型

具有多重入口时,对具体的传送作限定。对于 TCP,应将该字段设为 IPPROTO_TCP;而对于 UDP

则设为 IPPROTO_UDP。后续章节根据具体套接字应用将做详细解释。

2.4 套接字选项和 I/O控制命令

创建套接字后,通过套接字选项和 I/O 控制命令对各种属性进行操作,便可对套接字的行

为产生影响。有的选项只用于信息的返回,而有的选项则可在应用程序中影响套接字的行为。

ioctl 即 I/O 控制命令,也会对套接字的行为产生影响。本节着重讨论 4 个 Winsock 函数:

getsockopt()、setsockopt()、ioctlsocket()和 WSAIoctl(),每个函数都有大量的命令。下面将讨论

各函数需要的参数和可以使用的选项。

这些 I/O控制命令和选项大多定义在WINSOCK.H或WINSOCK2.H内,这具体取决于它们到

底从属于WinSock 1.1还是从属于WinSock 2。也有少数几个选项是Microsoft提供的程序或某种传

输协议所特有的。Microsoft特有的一些扩展定义在WINSOCK.H和MSWSOCK.H这两个头文件内。

需要注意的是,若应用程序使用了Microsoft特有的扩展,那么必须与MSWSOCK.LIB建立链接。

2.4.1 套接字选项

套接字选项值会影响套接字的操作。获得和设置套接字的函数分别是 getsockopt()和

setsockopt(),这两个函数的定义如下:

Page 12: 第2章 - tup.com.cn

34 | 网络程序设计应用教程

int getsockopt( SOCKET s, int level, int optname, char FAR *optval, int FAR

*optlen);

int setsockopt( SOCKET s, int level, int optname, const char FAR *optval, int

optlen);

其中:

� s:标识某套接字句柄。

� level:此选项被定义在哪个级别,可以选 SOL_SOCKET、IPPROTO_IP和 IPPROTO_TCP等。

� optname:表示套接字哪个选项的值被获得或设置。

� optval:指向一个缓冲区,存储所请求或要设置的选项的值。

� optlen:指定参数 optval所指缓冲区的大小。

如果函数成功,则返回 0;否则返回 SOCKET_ERROR。

TCP/IP 协议是分层的,而每层又有多个协议,这就造成了选项有不同的级别(level)。最高

层是应用层,这一层的属性对应着 SOL_SOCKET 级别,是对套接字自身的一些通用参数的配置;

再下一层是传输层,对应 IPPROTO_TCP级别;再往下是网络层,有 IP协议,在套接字选项上对

应 IPPROTO_IP 级别。各个级别的属性不同,同一级别的不同协议的属性可能也不同,所以在套

接字选项设定上一定要根据具体情况指定恰当的级别,并设置正确的选项参数。

下面具体说明不同级别的主要选项以及说明。

首先 level 选择 SOL_SOCKET 级别,常用的选项内容如表 2-4 所示。

表 2-4 SOL_SOCKET 级别套接字的常用选项

optname 选项名 说明 获得/设置 optval 类型

SO_ACCEPTCONN 套接字监听状态 只能获得 BOOL

SO_BROADCAST 允许收发广播数据 两者均可 BOOL

SO_ERROR 获得套接字错误 只能获得 int

SO_KEEPALIVE 保持连接 两者均可 BOOL

SO_LINGER 延迟关闭连接 两者均可 struct linger

SO_OOBINLINE 带外数据放入正常数据流 只能获得 BOOL

SO_RCVBUF 接收缓冲区大小 两者均可 int

SO_SNDBUF 发送缓冲区大小 两者均可 int

SO_RCVTIMEO 接收超时 两者均可 int

SO_SNDTIMEO 发送超时 两者均可 int

SO_REUSERADDR 允许重用本地地址和端口 两者均可 BOOL

SO_TYPE 获得套接字类型 只能获得 int

下面对这些选项做些详细解释。

Page 13: 第2章 - tup.com.cn

第 2 章 套接字基础 | 35

� SO_ACCEPTCONN的含义:如果套接字进入监听模式,值就返回 True。

� SO_BROADCAST 的含义:如果计算机需要发送广播数据或者接收其他计算机的广播数

据,必须设置这个选项为 True,否则接收和发送都可能出错。

� SO_KEEPALIVE 的含义:对于 TCP 的套接字,应用程序请求下层服务提供程序允许在

TCP 连接上使用保持活跃(Keepalive)数据包,此时如果连接中断,便会向套接字上正

在进行的任何一个调用返回一个WSAENETRESET错误代码。

注 意

数据报套接字不支持此选项。

� SO_LINGER 的含义:用于控制当未发送的数据在套接字上排队等候时,一旦执行了

closesocket()函数后应该采取什么样的操作。结构 linger包含延迟时间,该结构的定义如下:

struct linger {

u_short l_onoff;

u_short l_linger;

};

如果 l_onoff 是一个非零值,则意味着此时允许延迟,而 l_linger 对应的是延迟时间,以秒为

单位。若超出规定的延迟时间,便不再拖延,所以还未发送或接收的数据都会被丢弃。如果 l_onoff

的值是零,则表示此时不允许延迟。

注 意

数据报套接字不支持此选项。

调用 closesocket()函数的时候,对延迟选项的设置会直接影响到一个连接的行为。在表 2-5 中

对不同的行为进行了总结。

表 2-5 延迟选项的行为

间隔时间 关闭类型 是否等待关闭

0(l_onoff=0或者 l_linger=0) 强行关闭 否

>0 (l_onoff=1 而且 l_linger>0) 正常关闭 是

假如将 SO_LINGER 的间隔时间设为 0,调用 closesocket()函数就不会进入阻塞状态,即使队

列中的数据尚未发送,或者尚未收到确认。我们称这种情况为“强行关闭”或者“异常关闭”,因

为套接字虚拟通信回路会立即重设,尚未发出的所有数据都会丢失。而在回路的远程端,正在运行

的任何接收调用都会失败,同时返回WSAECONNRESET错误。

如果在一个阻塞套接字上间隔时间不为 0,那么 closesocket()调用也会在—个阻塞套接字上进

入阻塞或暂停状态,直到剩余的数据全部发出,或者超过规定的时间。我们将这称为“正常关闭”。

若超过规定的时间数据仍未全部发出,Windows套接字机制会在 closesocket()返回之前将连接终止。

� SO_OOBINLINE 的含义:在默认情况下 OOB 数据不是内嵌传送的,也就是说,若调用

一个接收函数(同时相应地设置MSG_OOB标志),那么 OOB数据也会放在一个单独的

Page 14: 第2章 - tup.com.cn

36 | 网络程序设计应用教程

调用中返回。若设置了这个选项,OOB 数据会出现在从一个接收调用返回的数据流中,

在设置了 SIOCATMARK选项的前提下,调用 ioctlsocket函数便可决定哪个字节属于OOB

数据。

注 意

数据报套接字不支持此选项。

� SO_RCVBUF的含义:用于返回或设置分配给套接字的数据接收缓冲区大小。

� SO_SNDBUF的含义:用于返回或设置分配给套接字的数据发送缓冲区大小。

� SO_RCVTIMEO 的含义:用于设置阻塞套接字上的接收超时值。这是以毫秒为单位的整

数,用于指出一个 WinSock 接收函数在接收数据时最多应阻塞多长时间。如需使用

SO_RCVTIMEO选项并用WSASocket函数创建套接字,应将WSA_FLAG_OVERLAPPED

指定为WSASocket的 dwFlags参数的一部分,以后对任何WinSock接收函数的调用(包

括 recv, recvfrom, WSARecv, WSARecvFrom等)都只会阻塞到指定的时间长度。假如在

这段时间内没有数据抵达,调用便会失败,并返回错误 10060(WSAETIMEDOUT)。如果

接收操作超时,套接字就会处于不确定状态而不应再使用。

� SO_SNDTIMEO 的含义:用于设置阻塞套接字上的发送超时值。这是以毫秒为单位的整

数,设定发送函数在发送数据时最多阻塞多久。假如需要使用 SO_SNDTIMEO选项并用

WSASocket 函数创建套接字,应将 WSA_FLAG_OVERLAPPED 设为 WSASocket()的

dwFlags参数的一部分,以后对任何WinSock发送函数的调用(包括 send,sendto, WSASend,

WSASendTo等)只会阻塞到指定数量的时间。如发送操作在这段时间内不能完成,调用

便会失败,并返回错误 10060(WSAETIMEDOUT)。如果发送操作超时,套接字就会处于

不确定状态而不应再使用。

� SO_REUSERADDR的含义:在默认情况下,套接字不能同一个正在被其他套接字使用的

本地地址绑定在一起,但少数情况下仍有必要以这种方式来实现对某个地址的重复利用。

每个连接都是通过它的本地及远程地址的组合来唯一标识的。针对我们想要连接的地址,

只要能用某些差异(例如 TCP/IP中采用不同的端口号)来维持这种唯一的特性,绑定就

被允许。唯一例外的是监听套接字。两个独立的套接字不可与同一个本地端口绑定到一

起来等待传入的连接通知。假定两个套接字都在同一个端口上进行监听,那么到底由哪

一个来接收传入的连接通知呢?这种情况是无法确定的。在 TCP的环境下,假如服务器

关闭或异常退出,造成本地地址和端口均进入 TIME_WAIT状态,那么 SO_REUSEADDR

这个套接字选项便显得非常有用。因为在 TIME_WAIT 状态下,其他任何套接字都不能

与这个端口绑定到一起,但如果设置了 SO_REUSERADDR 选项,服务器便可在重新启

动之后在相同的本地接口及端口上进行监听。

� SO_TYPE 的含义:用于返回指定套接字的类型。可能的套接字类型包括 SOCK_RAW,

SOCK_DGRAM和 SOCK STREAM等。

然后是 level选择 IPPROTO_IP级别,常用的选项内容如表 2-6所示。

Page 15: 第2章 - tup.com.cn

第 2 章 套接字基础 | 37

表 2-6 IPPROTO_IP 级别套接字的常用选项

optname 选项名 说明 获得/设置 optval 类型

IP_HDRINCL 在数据包中包含 IP 头部 两者均可 BOOL

IP_OPTINOS IP 头部选项 两者均可 char []

IP_TOS 服务类型 两者均可 int

IP_TTL 生存时间 两者均可 int

下面对这些选项做些详细解释。

� IP_HDRINCL的含义:若将该选项设为 True,发送函数会将 IPv4头包含在自己发送的数

据前面,因此在调用一个WinSock发送函数时必须在数据前面包含完整的 IPv4头,并对

IP 头的每个字段进行正确地写。设置这个选项之后,IPv4 协议栈将会在必要的时候对数

据包的数据部分进行分段。

注 意

这个选项仅对 SOCK_ RAW类型的套接字有效。

� IP_OPTINOS的含义:通过该选项可在 IP头内设置各种 IP选项。可能的选项如下。

� 安全和处理限制:具体参见 RFC1108。

� 记录路由:每个路由器都将自己的 IP地址添加到头内。

� 时标(时间戳):每个路由器都添加自己的 IPv4地址及时间。

� 松散源路由选择:数据包被要求去访问选项头内列出的每个 IPv4地址。

� 严格源路由选择:数据包被要求只能访问选项头内列出的那些 IPv4地址。

� IP_TOS的含义:TOS(type of service,服务类型)是在 IPv4头中设置的一个字段,用于

指出与一个包对应的具体特征。该字段总长度为 8 位,划分为 3 部分:一个 3 位长度的

优先级字段(这方面的设置会被忽略),一个 4位长度的 TOS字段,以及一个补足位(必

须设为 0)。其中,4个 TOS位分别对应于最短延迟、最高数据吞吐率、最大可靠性以及

最小费用。但要注意的是,每一次只能设置一个位。若这 4个位的值均为 0,表示采用普

通服务。在 RFC1340 文件中,针对各种标准应用(包括 TCP、SMTP、NNTP 等)分别

推荐了不同的设置位。此外,RFC1349文件也对早先公布的 RFC文件进行了一些补充纠

正。

� IP_TTL的含义:TTL字段需要在 IP头内进行设置。数据报利用 TTL字段对数据报能够

通过的最大路由器数量进行限制,这种限制的目的在于避免因路由循环而造成数据报永

无休止地在网络中游荡。其背后的道理在于,数据报每经过一个路由器都会将它的 TTL

值减去 1,若发现这个值变成了 0,数据报便会被丢弃。

然后是 level选择 IPPROTO_TCP级别,常用的选项内容如表 2-7所示。

Page 16: 第2章 - tup.com.cn

38 | 网络程序设计应用教程

表 2-7 IPPROTO_TCP 级别套接字的常用选项

optname 选项名 说明 获得/设置 optval 类型

TCP_MAXSEG TCP最大数据段的大小 两者均可 int

TCP_NODELAY 不使用 Nagle算法 两者均可 BOOL

下面对这些选项做详细解释。

� TCP_MAXSEG的含义:获得和设置 TCP连接的最大数据段大小,返回值是 TCP发送给

另一端的最大数据量。

� TCP_NODELAY的含义:为减少网络通信的开销、提升性能以及数据吞吐率,默认状态

下系统采用 Nagle算法。如果应用请求发送一批数据,系统在接收了这些数据之后可能会

稍候一段时间,等其他数据累积进来,然后统一发送出去,但如在规定的时间内并无新

数据加入,那么原先那些数据当然也会发送出去。在某些情况下 Nagle算法的行为反而会

产生不利的影响,本选项的目的便是禁止采用它。若网络应用程序通常只需发送数量相

当少的数据,同时要求能得到极其迅速的回应,使用这种算法反而会明显影响性能。Telnet

便是这样的一个典型例子。

2.4.2 I/O控制命令

套接字 ioctl()函数用于控制套接字上的 I/O行为,同时获得与那个套接字上挂起的 I/O操作的

有关信息。在Windows Sockets中向套接字发送 I/O控制命令的函数有两个:一个是源于WinSock 1.1

的 ioctlsocket(),另一个是WinSock 2新引进的WSAloctl()。

ioctlsocket函数可以控制套接字的 I/O模式。该函数的定义如下:

int ioctlsocket( SOCKET s, long cmd, u_long FAR *argp );

其中:

� s:一个已创建的套接字句柄。

� cmd:指定在套接字上要执行的命令。

� argp:指向 cmd参数的指针。

WinSock 2新引进的 I/O控制命令函数WSAIoctl()添加了一些新的选项,首先它增加了一些输

入参数,同时还增加了一些输出参数用来从调用中返回数据。另外,该函数的调用可以使用重叠

I/O。该函数的定义如下:

int WSAIoctl( SOCKET s, DWORD dwIoControlCode, LPVOID lpvInBuffer,

DWORD cbInBuffer, LPVOIDlpvOutBuffer, DWORD cbOutBuffer,

LPDWORD lpcbBytesReturned, LPWSAOVERLAPPEDlpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);

WSAloctl()函数提供了对套接字的控制能力。其中:

Page 17: 第2章 - tup.com.cn

第 2 章 套接字基础 | 39

� s:一个套接字的句柄。

� dwIoControlCode:描述将进行的操作的控制代码,在设置接收全部选项时使用

SIO_RCVALL。

� IpvlnBuffer:指向输入缓冲区的地址。

� cblnBuffer:描述输入缓冲区的大小。

� lpvOutBuffer:指向输出缓冲区的地址。

� cbOutBuffer:描述输出缓冲区的大小。

� lpcbBytesRetumed:一个输出参数,返回输出实际字节数的地址。

� lpOverlapped:指向WSAOVERLAPPED结构的地址。

� lpCompletionRoutine:指向操作结束后调用的例程指针。

最后两个参数在使用重叠 I/O时才有用。

表 2-8 列出了一些常用的 I/O 控制命令,其中需要向调用者返回数据的 I/O 控制命令只有

WSAloctl()函数支持。

表 2-8 I/O 控制命令

I/O 控制命令 含义

FIONBIO

将套接字置于非阻塞模式,此命令启动或关闭套接字 s 上的非阻塞模式。默认

情况下,所有的套接字在创建时都处于阻塞模式。如果要打开非阻塞模式,调

用 I/O控制函数时设置*argp为非 0;如要关闭非阻塞模式,则设置*argp为 0。

另外,WSAAsyncSelect()或 WSAEventSelect()自动设置套接字为非阻塞模式。

在这些函数调用后,任何试图将套接字设置为阻塞模式的调用都将以

WSAEINVAL 错误失败。为了将套接字设置为阻塞模式,应用程序必须首先通

过让网络事件 lEvent参数等于 0使得调用WSAAsyncSelect()无效,或者通过设

置 NetworkEvents参教等于 0使得调用WSAEventSelect()无效

FIONREAD

返回在套接字上要读的数据的大小。此命令用来确定可以从套接字上读多少数

据。对于 ioctlsocket(),argp 参数使用长整型来返回可读的字节数,当使用

WSAloctl()函数时在 lpvOutBuffcr参数中返回。如果是流式套接字,返回接收操

作可以接收数据的数量;如果是数据报套接字,返回排在套接字上的第一个消

息的大小

SlO_RCVALL 接收网络上所有的数据包,使用此命令允许在套接字上接收网络上的所有 IP

包,套接字必须绑定到一个明确的本地接口上

SIOCATMARK 确定带外数据是否可读

2.5 套接字的 I/O模式

在计算机中需要处理各种设备的输入与输出(I/O)操作,使用网络设备进行数据的接收发送

也有类似的 I/O操作。

Page 18: 第2章 - tup.com.cn

40 | 网络程序设计应用教程

2.5.1 网络通信中的 I/O等待

网络设备操作中经常面临 I/O事件的等待,通常有如下几类。

(1)等待输入操作:等待网络中有可接收的数据。

(2)等待输出操作:等待有足够的网络缓冲区保存待发送的数据。

(3)等待连接请求:等待有新的客户端建立连接或已连接的客户端断开连接。

(4)等待连接响应:等待服务器对连接的响应。

(5)等待异常:等待网络连接异常或有带外数据可被接收。

对于这些等待情况的处理,通常是以阻塞方式持续等待 I/O条件满足,或者以轮询方式检查 I/O

条件是否满足。

2.5.2 套接字的 I/O模式

在了解套接字的 I/O模式之前,需要搞清楚同步和异步的概念。

所谓同步,就是调用者调用一个功能后,在该调用没返回结果前不会继续执行后续操作。简单

来说,同步就是必须一件一件事做,等前一件做完了才能做下一件事。

异步与同步是相对而言的。当一个异步过程调用发出后,调用者在没有得到结果之前就可以继

续执行后续操作,当这个调用完成后,一般通过状态、通知和回调来通知调用者。对于异步调用,

调用的返回并不受调用者控制。通知调用者有 3种方式,具体如下。

(1)状态:即轮询被调用者的状态,调用者需要进行定期检查,因此效率会很低。

(2)通知:当被调用者执行完成后,发出通知告知调用者,无须消耗太多性能。

(3)回调:与通知类似,当被调用者执行完成后,会执行调用者提供的回调函数。

由此可以看出,同步和异步主要涉及的是调用一个功能后消息的通知机制。

套接字的 I/O模式可分为阻塞和非阻塞两种模式。

阻塞模式是指在指定套接字上调用函数执行操作时,在执行没有完成之前函数不会立即返回。

例如,阻塞模式下调用 recv()函数接收数据时会阻塞,直到接收缓冲区有数据后才会读取数据并从

阻塞等待中返回。

非阻塞模式是指在指定套接字上调用函数执行操作时,无论操作是否完成,函数都会立即返回。

例如,在非阻塞模式下程序调用 recv()函数接收数据时,程序会直接读取套接字实现中的接收缓冲

区,无论是否读取到数据,函数都会立即返回,而不会一直在此函数上阻塞等待。

从上面的介绍看,同步与阻塞、异步与非阻塞似乎有很多相同点,但它们并不是两对相同

的概念。同步和异步关心的是消息的通知机制,阻塞和非阻塞则关心的是消息的处理机制。

实际上同步操作也可以是非阻塞的,比如以轮询的方式处理网络 I/O事件,消息的通知方式仍

然是主动等待,但在消息处理发生阻塞时并不等待;异步操作也是可以被阻塞住的,比如在使用

I/O复用模型时,当关注的网络 I/O事件没有发生时,程序会在 select()函数调用处阻塞。

Page 19: 第2章 - tup.com.cn

第 2 章 套接字基础 | 41

在Windows系统中提供了 7种套接字的 I/O模式:阻塞 I/O模式、非阻塞 I/O模式、I/O复用

模式、WSAAsyncSelect模式、WSAEventSelect模式、重叠 I/O模式和完成端口模式。阻塞与非阻

塞的 I/O模式是网络通信中 I/O处理的常用方式,而它们又有各自的适用的场景,需要根据实际需

求来选择网络应用程序使用哪种 I/O模式。

下面分别介绍最常用的三种套接字 I/O模式。

1. 套接字 I/O阻塞模式

在默认的情况下,新建的套接字都是阻塞模式的,这种模式下应用程序会持续等待网络事件的

发生,直到网络 I/O条件满足为止。

在后面 3.2.2小节的阻塞流式套接字的编程模式实例中能够看到 I/O阻塞模式的具体使用。

阻塞 I/O模式是网络通信中进行 I/O操作常见的一种模式,这种模式的优点是使用非常简单方

便。缺点是当需要处理多个套接字连接时,串行处理多套接字的 I/O操作会导致处理时间延长、程

序执行效率降低等问题。

2. 套接字 I/O非阻塞模式

区别于阻塞 I/O模式,在非阻塞模式中应用程序不会持续等待网络事件的发生,而是以轮询接

口函数的方式不断地询问网络状态,直到网络 I/O条件满足为止。

在Windows Sockets中,可以调用 ioctlsocket()函数和WSAloctl()函数将套接字设置为非阻塞模

式,函数具体定义参见 2.4.2小节。

在后面 3.2.2小节的非阻塞流式套接字的编程模式实例中能够看到 I/O非阻塞模式的具体使用。

非阻塞 I/O模式的优点是用简单的方法来防止套接字在 I/O操作上阻塞等待,让应用进程在等

待网络条件满足的这段时间内做其他的事情。在进行轮询接口函数的间隙可以对其他套接字的 I/O

操作进行类似的尝试,对于多个套接字的网络 I/O而言,非阻塞 I/O模式可以避免串行等待 I/O带

来的效率低下问题(解决了阻塞 I/O模式存在的缺点)。

非阻塞 I/O模式的缺点是,由于应用程序需不断轮询接口函数直到成功完成指定的操作,这占

用了较多 CPU 的资源。另外,如果设置了较长的间隙时间,那么后一次成功的 I/O 操作对于 I/O

事件发生而言有滞后时间,因此不适合对实时性要求比较高的应用。

3. 套接字 I/O复用模式

套接字 I/O 复用模式也称为选择模式(select 模式),它可以让网络应用程序同时对多个套接

字进行管理。I/O复用模式是建立在内核提供的多路分离函数 select()基础之上的,使用 select()函数

可以避免非阻塞 I/O模式中轮询等待的问题。

使用 select()函数进行 I/O 请求和同步阻塞模型没有太大的区别,但使用 select()函数以后最大

的好处是用户可以在一个线程内同时处理多个套接字的 I/O请求。用户可以注册多个套接字的不同

网络事件,然后不断地调用 select()及时捕获到最先满足条件的 I/O 事件,发现被激活的套接字,

即可达到在同一个线程内同时处理多个 I/O请求的目的。

select()函数可以注册一组套接字的状态,通常用于操作处于就绪状态的套接字。在 select()函

数中使用 fd_set结构来管理多个套接字,fd_set也称为套接字集合,该结构的具体定义如下:

Page 20: 第2章 - tup.com.cn

42 | 网络程序设计应用教程

typedef struct fd_set {

u_int fd_count;

SOCKET fd_array[FD_SETSIZE];

} fd_set;

其中:

� fd_count:集合中包含的套接字数量。

� fd_array:集合中包含套接字句柄的数组。

� FD_SETSIZE:在 winsock2.h中定义的数组大小,默认值为 64。

select()函数的定义如下:

int select( int nfds, fd_set FAR *readfds, fd_set FAR *writefds,

fd_set FAR *exceptfds,const struct timeval FAR *timeout);

其中:

� nfds:忽略,只是为与 BSD Socket兼容而保留的。

� readfds:指向套接字集合的指针,用来检查集合中套接字的可读性。

� writefds:指向套接字集合的指针,用来检查集合中套接字的可写性。

� exceptfds:指向套接字集合的指针,用来检查集合中套接字的错误。

� timeout:指定此函数最大等待时间,如果参数设置为 NULL,函数则进入阻塞模式(永

久等待),如果参数设置为 0,函数则立即返回结果,如果参数设置为非 0,函数则进入

限时等待。

函数返回产生网络事件的套接字数量的总和。如果函数等待超时则返回 0,函数出现错误则返

回 SOCKET_ERROR。

select()函数的三个参数 readfds,writefds和 exceptfds中至少要有一个不能为 NULL,且套接字

集合中至少要包含一个套接字句柄,否则 select()函数将没有要等待检查的套接字。

参数 readfds中包含有待检查可读性的套接字,在满足以下条件时 select()函数返回。

(1)当套接字处于监听状态时,有客户端连接请求到来。

(2)当套接字处于非监听状态时,套接字接收缓冲区有数据可读。

(3)套接字连接已经关闭、重置或终止。

参数 writefds中包含有待检查可写性的套接字,在满足以下条件时 select()函数返回。

(1)已经调用非阻塞的 connect()函数进行连接且连接成功。

(2)套接字有数据可以发送。

exceptfds 中包含有待检查带外数据以及异常错误的套接字,在满足以下条件时 select()函数返

回。

Page 21: 第2章 - tup.com.cn

第 2 章 套接字基础 | 43

(1)调用非阻塞的 connect()函数进行连接但连接失败。

(2)有带外数据可以读取。

为了方便对 fd_set集合进行操作,winsock2.h中定义了四个宏来实现对套接字句柄集合的各种

操作。

� FD_ZERO(*set):将 set集合清空,套接字集合使用前必须初始化,即清空。

� FD_SET(s,*set):将套接字 s添加到集合 set中。

� FD_CLR(s,*set):从集合 set中删除套接字 s。

� FD_ISSET(s,*set):测试套接字 s是否在集合 set中,在返回非零,否则返回零。

使用 select()函数的基本过程如下:

(1)调用 FD_ZERO()初始化套接字集合。

(2)调用 FD_SET()将套接字添加到感兴趣的 fd_set集合中。

(3)调用 select()函数,此后程序处于等待状态,等待集合中套接字上的 I/O活动事件,每个

I/O活动事件都会设置相应集合中的套接字句柄,然后返回集合中设置的套接字句柄总数,并更新

相关集合,即删除不存在的等待 I/O操作的套接字句柄,留下的是需要处理的套接字句柄。

(4)调用 FD_ISSET()检查指定的套接字是否仍在集合中,如果在,则可以对其进行与集合相

对应的 I/O处理。

2.6 习题

1. 在套接字通信中,常用套接字类型有哪些?

2. 与套接字选项、I/O控制相关的套接字函数主要有哪些?

3. Windows系统中有哪些套接字 I/O模式?

Page 22: 第2章 - tup.com.cn

第 3 章

流式套接字编程

网络编程是一个与协议原理关系密切的实现过程,因此要学习某种网络编程模式必须先了解

与之相关的协议。本章从 TCP(Transmission Control Protocol)协议的原理出发,阐明流式套接字

编程的适用场合,介绍流式套接字编程的基本模型以及相关函数的使用细节,并用实例来说明流式

套接字编程的具体实现过程。

3.1 TCP传输协议

要学习流式套接字编程模式就要了解和它相关的 TCP协议,TCP协议是 TCP/IP协议簇中传输

层的重要协议之一。

3.1.1 TCP报文格式

TCP数据报被封装在 IP数据报中。图 3-1显示了 TCP的报文具体格式,如果不考虑选项部分,

TCP头部的大小是 20字节。

16位源端口号 16位目的端口号

32位序列号

32位确认序号

4位首

部长

选项

数据

16位校验和 16位紧急指针

保留(6

位)URG ACK PSH PST SYN FIN 16位窗口大小

0 15 16 31

TCP头部

(20字节)

图 3-1 TCP报文格式

Page 23: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 45

TCP头部的各字段解释如下。

� 源端口号:用于标识发送端的应用进程(程序)。

� 目的端口号:用于标识接收端的应用进程(程序)。

� 序列号:32 位的序列号标识了发送端发送报文的第一个字节在传输中对应的字节序号。

当 SYN出现,序列码实际上是初始序列码(Initial Sequence Number,ISN),而第一个

数据字节是 ISN+1。

� 确认序号:32位的确认序号标识了报文发送端期望接收的字节序列。如果设置了 ACK控

制位,这个值表示一个准备接收的包的序列码,因此确认序号应该是上次已成功接收的

数据字节序号加 1。

� 头部长度:TCP头部大小(以 32位为单位),指示数据从何处开始,通常为 5(表示报

头有 20字节的定长部分且没有可选的变长部分)。

� 标志位:TCP头部通常有 6个标志位。按顺序分别是:URG表示紧急指针是否有效;ACK

表示确认序号是否有效;PSH 直接将接收到的数据提交给应用层;RST 复位相应的 TCP

连接;SYN表示同步序号有效,发起一个 TCP连接;FIN表示结束一个 TCP连接。

� 窗口大小:表示想接收的每个 TCP数据段的大小,窗口大小为字节数,起始于确认序号

字段指明的值,这个值是接收端正期望接收的字节。窗口大小最大为 65535字节。

� 校验和(Checksum):发送端基于数据包的内容计算一个校验和,接收端接收到的数值

要与发送端发送的数值完全一样,才能证明数据的有效性。接收端校验和校验失败的时

候会直接丢掉这个收到的数据包。

� 紧急指针:只有当 URG标志设置为 1时,这个指针指向的紧急数据才有效,表示要加快

处理标示为紧急的数据段。如果未设置 URG标志时,紧急指针无效。

� 选项:长度不定但必须是 32位(Bit)的整数倍。常见的选项包括最大报文段大小(Maximum

Segment Size,简称MSS)、时间戳(Timestamp)等。

� 数据:TCP 报文中的数据部分是可选的,比如在连接建立和连接终止时,双方交换的报

文段仅有 TCP头部。

3.1.2 TCP协议的传输特点

TCP协议是 TCP/IP协议簇中最主要的传输层协议。TCP协议是一个面向连接的数据传输服务,

提供高可靠性字节流传输服务,主要用于一次传输要交换大量报文的应用需求。为了维护传输的可

靠性,TCP协议增加了许多开销,如确认、流量控制、计时器以及连接管理等。

TCP协议传输具有以下特点。

(1)面向连接的传输:客户端应用程序和服务器之间传输数据前必须先建立 TCP连接,在传

输数据完毕后必须释放已建立的 TCP连接。

(2)仅支持单播传输:也就是端到端的通信。TCP连接是端到端的,客户端应用程序在一端,

服务器在另一端。不支持广播和多播方式。

Page 24: 第2章 - tup.com.cn

46 | 网络程序设计应用教程

(3)可靠连接:保证 TCP连接可靠建立,建立连接时要测试网络的连通性。如果有故障发生,

阻碍数据包到达远端系统,或者服务器不接受连接,那么试图连接就会失败,此时客户端就会得到

错误通知。

(4)可靠交付:一旦建立连接,TCP保证数据将按发送时的顺序交付,没有丢失也没有重复,

如果因为故障而不能可靠交付,发送方会得到通知。

(5)流模式:TCP从发送方向接收方发送数据,是在不保留报文边界的情况下以字节流的方

式进行传输。

(6)具有流量控制的传输:TCP控制数据传输的速率,防止发送方传送数据的速率快于接收

方的接收速率,因此 TCP传输数据不用考虑计算机处理数据的速率。

(7)支持全双工传输:TCP允许通信双方的应用程序在任何时候都能发送数据且不会相互影

响,因为 TCP连接的两端都设有缓存,用来临时存放双向通信的数据。

(8)每次发送的数据段大小是可变的:每次发送多少字节的数据不是固定的,大小不是由当

前可用缓存决定的,而是根据双方给出的窗口大小和当前网络的拥塞程度决定。

3.1.3 TCP连接的建立与关闭

1. TCP连接的建立

建立一个 TCP 连接需要客户端和服务器总共发送三个数据报文,因此这个过程也称为

TCP 三次握手,如图 3-2 所示。

具体步骤描述如下:

(1)客户端发送一个 SYN标志为 1的请求报文指明客户端打算连接的服务器端口号和初始序

号 x(Initial Sequence Number,简称 ISN),请求报文发送后客户端进入 SYN-SENT状态。

(2)服务器启动后首先要进入 LISTEN 状态,当它收到客户端发来的 SYN 请求后进入

SYN-RCVD状态,发送 SYN标志和 ACK标志都为 1的应答报文,报文包含服务器的初始序号 y,

同时将确认序号设置为客户端的 ISN 加 1(即 x+1),对客户端的 SYN 请求报文进行确认。一个

SYN将占用一个序号。

(3)客户端接收到服务器的确认报文后进入ESTABLISHED状态,表明本方连接已成功建立,

发送 ACK标志为 1的应答报文,报文包含序号为 x+1(因为 SYN占用了一个序号)和确认序号设

置为服务器的 ISN 加 1(即 y+1),对服务器的 SYN 报文段进行确认,当服务器接收到该确认报

文后也进入 ESTABLISHED状态。

经过以上 3个步骤,客户端就和服务器建立起了 TCP连接。

Page 25: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 47

CLOSED CLOSED

SYN-SENT

LISTEN

SYN-RCVD

ESTABLISHED

ESTABLISHED

客户端 服务器 建立TCB主动

打开

数据传输

建立TCB被动

打开

图 3-2 TCP连接的建立(三次握手)

2. TCP连接的关闭

通常 TCP的关闭都是由客户端来主动发起的,因为一般客户端程序都是由用户来操作使用的。

关闭一个 TCP 连接,需要客户端和服务器总共发送四个数据报文以确认连接的关闭,因此这个过

程也称为 TCP四次挥手,如图 3-3所示。

由于 TCP连接是全双工的,因此每个方向都需要单独进行关闭,即当 A方完成数据发送任务

后,发送一个 FIN来终止 A到 B方向的连接,而收到一个 FIN的 B方不会再收到数据,但是此时

B方仍然能够发送数据,直到 B方也发送了 FIN。首先进行关闭的一方将执行主动关闭,而另一方

则执行被动关闭。

具体步骤描述如下:

(1)客户端主动发起关闭连接请求。客户端发送一个 FIN标志为 1的报文,报文包含客户端

初始序号 u,用来关闭从客户端到服务器的数据传送,此时客户端进入 FIN_WAIT_1状态。

(2)当服务器收到这个FIN请求后,它发回一个ACK标志为1的确认报文,进入CLOSE_WAIT

状态。报文包含服务器的序号 v以及确认序号为收到的序号加 1(即 u+1)。客户端收到该确认报

文后进入 FIN_WAIT_2状态,表明本方连接已关闭,但仍可以接收服务器发来的数据。与 SYN 一

样,一个 FIN将占用一个序号。

(3)接着服务器程序关闭本方连接,发送一个 FIN标志为 1的报文,进入 LAST_ACK状态。

报文包含服务器的序号 w以及确认序号为收到的序号加 1(即 u+1)。当客户端接收到该报文段后

进入 TIME_WAIT状态。注意:w不一定是 v+1,因为此时服务器还可以发送数据。

(4)客户端在收到服务器发来的 FIN 请求后,它发回一个 ACK标志为 1 的确认报文,并将

确认序号设置为收到的序号加 1(即 w+1)。发送确认报文后客户端进入 TIME-WAIT状态,等待

两个MSL (Maximum Segment Lifetime,报文最大生存时间)后客户端关闭,而服务器接收到该

确认报文后,服务器连接关闭。

Page 26: 第2章 - tup.com.cn

48 | 网络程序设计应用教程

图 3-3 TCP连接关闭(四次挥手)

3.2 流式套接字的编程模型

流式套接字用于提供面向连接、可靠的数据传输服务,该服务将保证数据能够实现无差错、

无重复发送,并按顺序接收。流式套接字之所以能够实现可靠的数据传输服务,原因在于其使

用了传输控制协议,即上一节介绍的 TCP 协议。

根据上一节的内容我们知道,TCP 协议的传输特点是面向连接、可靠交付的一对一(单播)

的数据传输,因此一般下列情况时可以采用基于流式套接字的网络编程:

(1)大量数据的传输应用。流式套接字适合大量数据的传输的应用,传输任意大及任意格式

的数据,比如音视频数据。在这种应用场景下,数据传输量大,对数据传输的可靠性要求比较高,

且与数据传输的代价相比,连接维护的代价微乎其微。

(2)要求高可靠性的传输应用。流式套接字适合应用在可靠性要求高的传输应用中,比如在

线交易。在这种情况下,可靠性是传输过程首先要满足的要求,如果选择使用其他不可靠的传输服

务承载数据,那么为了避免数据丢失、乱序、重复等问题,程序设计时必须要考虑如何解决以上诸

多问题,由此会带来高昂的编码代价,而流式套接字则在协议层面解决了这些问题。

3.2.1 流式套接字的通信流程

流式套接字的网络通信是在 TCP连接建立的基础上进行的,而 TCP连接是由服务器和客户端

两方实现的。下面分别从服务器和客户端来描述流式套接字的通信流程。

Page 27: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 49

1. 服务器的通信流程

在流式套接字的通信流程中,服务器是服务提供方,被动接收客户端的连接请求。服务器基本

通信流程如下:

(1)创建套接字,并指定套接字类型为流式套接字。

(2)绑定本地的地址和通信端口。

(3)启动监听,等待客户端的连接请求。

(4)和客户端连接成功。

(5)进行数据发送和接收。

(6)关闭套接字。

2. 客户端的通信流程

在流式套接字的通信流程中,客户端是服务请求方,主动发起连接请求。客户端基本通信流程

如下:

(1)创建套接字,并指定套接字类型为流式套接字。

(2)设置服务器的地址和通信端口。

(3)向服务器发起连接请求。

(4)和服务器连接成功。

(5)进行数据发送和接收。

(6)关闭套接字。

3.2.2 流式套接字的编程模式

基于以上对流式套接字的通信流程的分析,结合相关套接字函数以及实际通信中的交互时序,

给出流式套接字的基本编程模式。根据流式套接字的网络通信 I/O模式,通常分为阻塞流式套接字

和非阻塞流式套接字,下面分别介绍这两种情况下流式套接字的编程模式。

1. 阻塞流式套接字的编程模式

阻塞流式套接字是最基本的流式套接字通信模式,默认情况下新创建的套接字都是阻塞模式。

阻塞流式套接字的编程模式如图 3-4所示。

Page 28: 第2章 - tup.com.cn

50 | 网络程序设计应用教程

图 3-4 阻塞流式套接字的编程模式

从编程模式可以看出,服务器进程要先于客户端进程启动。对服务器每个步骤说明如下:

(1)调用 socket()函数创建一个流式套接字,返回套接字句柄 s。如使用WinSock编程,需要

先调用WSAStartup()函数加载Windows Sockets DLL。

(2)调用 bind()函数将套接字 s绑定到一个本地的端点地址(IP地址+端口号)上。

(3)调用 listen()函数启动套接字 s监听,表示准备好接收来自客户端的连接请求。

(4)调用 accept()函数等待接受客户端的连接请求。

(5)如果收到客户端的连接请求,accept()函数返回得到新的套接字句柄 c1。

(6)调用 recv()函数在套接字 c1上接收来自客户端的数据。

(7)处理客户端的请求得到结果数据,调用 send()函数在套接字 c1上向客户端发送数据。

(8)与客户端的通信结束后,服务器可以调用 shutdown()函数通知对方不再发送或接收数据,

也可以由客户端进程断开连接,随后服务器进程调用 closesocket()函数关闭套接字 c1。

(9)如果要退出服务器进程,则调用 closesocket()函数关闭最初的套接字 s。

对客户端每个步骤说明如下:

Page 29: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 51

(1)调用 socket()函数创建一个流式套接字,返回套接字 c。同样,如使用WinSock编程,需

要先调用WSAStartup()函数加载Windows Sockets DLL。

(2)调用 connect()函数将套接字 c连接到服务器。

(3)调用 send()函数向服务器发送请求数据,调用 recv()函数接收来自服务器的回应数据。

(4)与服务器的通信结束后,客户端进程可以调用 shutdown()函数通知对方不再发送或接收

数据,也可以由服务器进程断开连接,随后客户端进程调用 closesocket()函数关闭套接字 c。

由此可以看出,服务器和客户端在通信过程中的角色不同,因此在编程上是有差别的。

2. 非阻塞流式套接字的编程模式

非阻塞流式套接字需要使用 I/O控制命令来设置。非阻塞流式套接字的编程模式如图 3-5所示。

图 3-5 非阻塞流式套接字的编程模式

对非阻塞流式套接字的编程模式下服务器每个步骤说明如下:

(1)调用 socket()函数创建一个流式套接字,返回套接字句柄 s。如使用WinSock编程,需要

先调用WSAStartup()函数加载Windows Sockets DLL。

Page 30: 第2章 - tup.com.cn

52 | 网络程序设计应用教程

(2)调用 bind()函数将套接字 s绑定到一个本地的端点地址(IP地址+端口号)上。

(3)调用 ioctlsocket()函数使用 FIONBIO控制命令,当值为非零时设置套接字 s为非阻塞模式。

(4)调用 listen()函数启动套接字 s监听,表示准备好接收来自客户端的连接请求。

(5)调用 accept()函数等待客户端的连接请求。

(6)如果收到客户端的连接请求,accept()函数返回得到新的套接字句柄 c1并跳到步骤(8)。

(7)调用WSAGetLastError()函数判断标志,如果为WSAEWOULDBLOCK则跳到步骤(5),

否则出错,进行错误处理。

(8)调用 recv()函数在套接字 c1上接收来自客户端的数据。

(9)处理客户端的请求得到结果数据,调用 send()函数在套接字 c1上向客户端发送数据。

(10)与客户端的通信结束后,服务器可以调用 shutdown()函数通知对方不再发送或接收数据,

也可以由客户端进程断开连接,随后服务器进程调用 closesocket()函数关闭套接字 c1。

(11)如果要退出服务器进程,则调用 closesocket()函数关闭最初的套接字 s。

对客户端每个步骤说明如下:

(1)调用 socket()函数创建一个流式套接字,返回套接字 c。同样,如使用WinSock编程,需

要先调用WSAStartup()函数加载Windows Sockets DLL。

(2)调用 connect()函数将套接字 c连接到服务器。

(3)调用 send()函数向服务器发送请求数据,调用 recv()函数接收来自服务器的回应数据。

(4)与服务器的通信结束后,客户端进程可以调用 shutdown()函数通知对方不再发送或接收

数据,也可以由服务器进程断开连接,随后客户端进程调用 closesocket()函数关闭套接字 c。

由此可以看出,阻塞流式套接字编程和非阻塞流式套接字编程只是在服务器上有所不同,而客

户端则是一样的。

3.3 相关套接字函数

前面已经介绍过,WinSock API是一套编程接口,提供的接口函数让调用者可以非常直观地操

作 TCP/IP协议,帮助网络应用程序方便地读取或改变底层套接字结构中的内容,写入或读取协议

交互的具体数据。本节围绕流式套接字编程的几个基本操作,具体介绍相关套接字函数的使用,以

及套接字的具体实现所关联的数据结构和底层协议的具体工作细节。

注 意

使用WinSock API之前都要先调用WSAStartup()函数加载Windows Sockets DLL,使

用完毕后要调用WSACleanup()函数释放Windows Sockets DLL,具体参见 2.3.4小节。

3.3.1 创建和关闭套接字

要实现流式套接字编程,网络应用程序首先要求创建套接字的实例,这在 2.3.7小节里已经做

Page 31: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 53

过简单介绍。完成创建任务的函数是 socket()和WSASocket()。socket()函数通常情况是实现同步传

输套接字的创建,默认情况是阻塞模式;WSASocket()函数相对 socket()函数增加了套接字组的声

明,因此可以用于异步传输。具体说明如下。socket()函数的定义是:

SOCKET socket(int af, int type, int protocol);

其中:

� af:确定套接字的通信地址族。套接字 API 为网络通信提供通用接口,常用的地址族有

AF_INET、AF_INET6、AF_UNIX(Unix套接字)、AF_ROUTE等。本书重点关注 AF_INET

(IPv4地址族)。

� type:指定套接字类型。套接字类型决定了采用的底层网络传输服务,常用的套接字类型有

SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET

等。本书重点介绍流式套接字(SOCK_STREAM)、数据报套接字(SOCK_DGRAM)和

原始套接字(SOCK_RAW)。

� protocol:指定要使用的传输协议。常用的协议有 IPPROTO_TCP、IPPTOTO_UDP、

IPPROTO_IP等。要注意的是,type和 protocol不能随意组合,如 SOCK_STREAM是和

IPPROTO_TCP 组合,SOCK_DGRAM 是和 IPPROTO_UDP 组合,SOCK_RAW 是和

IPPROTO_P组合。如果把常量 0作为参数,系统默认选择对应于指定地址族和套接字类

型对应的默认协议。在 TCP/IP协议簇中,目前对于流式套接字只有一种选择,我们可以

指定 0允许系统默认选择协议。

WSASocket()函数的定义是:

SOCKET WSASocket (

int af, int type, int protocol,

LPWSAPROTOCOL_INFO lpProtocolInfo,

GROUP g,

DWORD dwFlags);

WSASocket()的前三个参数与 socket()的参数含义相同,除此之外它还另外增加了 3个参数。

� lpProtocolInfo:一个指向WSAPROTOCOL_INFO结构的指针,该结构定义所创建套接口

的特性。如果本参数非零,则前三个参数(af, type, protocol)被忽略。

� g:保留给未来的套接字组使用,套接字组的标识符。

� dwFlags:套接字组属性的描述。

其中WSAPROTOCOL_INFO结构保存了指定协议的完整信息。

socket()函数和WSASocket()函数的返回值是套接字句柄(Handle),类似于文件描述符。

WSASocket()函数是 WinSock 为 Windows 平台扩展的,而 socket()是通用网络编程接口。

WSASocket()的发送操作和接收操作都可以被重叠使用(重叠 I/O模式),即接收函数可以被多次

调用,发出接收缓冲区,准备接收到来的数据,同样发送函数也可以被多次调用,组成一个发送缓

冲区队列。

Page 32: 第2章 - tup.com.cn

54 | 网络程序设计应用教程

在之后的套接字 API 函数调用过程中,套接字句柄(也称为套接字描述符)作为输入参数被

传递给调用函数,以标识要在其上执行操作的套接字抽象层。该句柄是一个整数:非负值表示成功,

-1(INVALID_SOCKET)表示失败。

当使用套接字完成了应用程序的网络通信后,调用 closesocket()来关闭套接字。该函数的定义

如下:

int closesocket ( SOCKET s );

closesocket()通知底层协议栈关闭通信,释放与套接字关联的所有资源。如果成功,返回 0;

否则返回-1(INVALID_SOCKET)。一旦调用了 closesocket(),之后不能再操作该套接字。

3.3.2 绑定地址

网络应用程序使用套接字时,需要明确它们将要用于通信的本地和远端端点地址才能连接并进

行数据传递。当套接字创建后,仅仅明确了该套接字即将使用的地址族,但没有关联具体的端点地

址。在 2.3.6小节我们讨论了套接字地址的描述,比如存储结构和一些转换方法。本节探讨如何将

套接字与本地和远端端点地址相关联,以及应用程序如何从套接字层次获得实际通信套接字的端点

地址。

bind()函数通过将一个本地名字赋予一个未命名的套接字,实现将套接字与本地地址相关联。

如果成功,bind()函数返回 0;否则返回-1。该函数的定义如下:

int bind (SOCKET s, const struct sockaddr *addr, int namelen);

其中:

� s:调用 socket()返回的套接字句柄。

� addr:地址参数,被声明为一个指向 sockaddr结构的指针。对于 TCP/IP应用程序,通常

使用 sockaddr_in(IPv4)对地址进行赋值,然后将指针强制类型转换为指向 sockaddr结构的

指针作为 bind()函数的参数。

� namelen:地址结构 addr的大小。

addr参数设置端点地址,包括 IP地址和端口号。bind()函数通常用的端点地址可以有两类。

(1)常规地址:包括一个指定的 IP地址和一个端口号。

(2)通配地址:可以将 sockaddr 设定为任意地址,即用常数 INADDR_ANY(0x00000000)的

IP地址和一个端口号。

如果为套接字指定的地址为 INADDR_ ANY,表示套接字可以接收任意 IP发来的数据或发送

数据到任意 IP,也就是说,如果本地计算机上有多个网络接口(网卡),这些网络接口都将与该

套接字关联,由该套接字来处理数据。服务器进程提供服务的端口通常为设计人员为该服务预留的

端口,因此服务器进程通过 bind()函数可以准确无误地将一个套接字与本地的主机地址和这个知名

端口关联起来,而客户端进程由于是请求服务的一方,设计人员没有必要为其指定固定的端口。如

Page 33: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 55

果客户进程通过 bind()函数强行将套接字与某一个端口绑定的话,有可能这个端口已经被其他套接

字使用了,则此时会发生冲突,使通信无法正常进行。因此,对于客户端进程,不鼓励将套接字绑

定到一个确定的端口上。

通过上述分析可以明确,在客户端和服务器进行真正的数据通信前,双方的端点地址已经明确,

且与套接字关联在一起了。但如果使用的是通配地址,则是由系统在程序运行过程中选择并关联的,

此时如何知道套接字的具体关联地址?另外,当流式套接字建立好连接时,其远端的端点地址也相

应地与套接字关联起来,这些信息会保存在为该套接字分配的内存结构中,但设计人员可能并不知

道确切的地址。

下面介绍如何获得套接字的关联地址,这可以使用 getsockname()函数和 getpeername()函数来

实现。

使用 getsockname()函数能够获得套接字关联的本地端点地址。该函数的定义如下:

int getsockname (SOCKET s,struct sockaddr FAR *name,int FAR *namelen);

该函数用于在对本地套接字调用了 bind()或 connect()之后获得这个套接字的名字信息,即这个

套接字所绑定的本地主机地址和端口号,将其保存在参数 name中返回。

使用 getpeername()函数能够获得套接字关联的远端端点地址。该函数的定义如下:

Intqetpeername (SOCKET s, struct sockaddr FAR *name, int FAR *namelen);

该函数用于获得已连接套接字的对等方端点地址,并保存在参数 name中返回。

3.3.3 监听套接字

服务器通过 bind()函数将套接字和指定的端点地址关联在一起,接下来要做的是将套接字设置

为监听模式,此时该套接字可以接受客户端来连接了。将套接字设置为监听模式的套接字函数是

listen(),其定义如下:

int listen (SOCKET s, int backlog );

� s:调用 socket()返回的套接字句柄。

� backlog:指定了被挂起的连接的最大队列长度,即同时可以有几个客户端请求连接。

同时可以有几个客户端请求连接,本身就存在着限制,这个限制是由下层协议提供的程序决定

的。如果参数 backlog超过下层协议的限制,那么系统会用与之最接近的一个合法值来取代。

listen()函数调用后服务器进入监听状态,使该套接字对进入的客户端连接请求进行排队,以便

应用程序可以接收处理它们。

listen()函数最常见的错误是 WSAElNVAL,该错误表示调用 listen()之前没调用 bind()绑定

函数。如果调用 bind()函数后使用 listen()接收到 WSAEADDRINUSE 错误,则表示绑定的地址

已经被占用。

Page 34: 第2章 - tup.com.cn

56 | 网络程序设计应用教程

3.3.4 连接套接字

要在服务器和客户端之间进行网络通信,需要连接客户端的流式套接字与服务器的流式套接字。

在建立连接的过程中,客户端与服务器的操作不同。客户端作为主动发起连接的一方主动请求,等

待服务器响应;服务器作为被动接受连接的一方,需要为连接准备接收缓冲区,接收客户端的连接

请求,并创建一个新的套接字作为和客户端独立的传输通道。

1. 客户端请求连接

客户端调用函数 connect()请求与服务器连接。该函数的定义如下:

int connect (SOCKET s, const struct sockaddr FAR *name, int namelen);

其中:

� s:调用 socket()返回的套接字句柄。

� name:被声明为一个指向 sockaddr结构的指针,该指针指向的结构保存服务器的 IP地址

和端口号。

� namelen:指明地址结构的长度,通常为 sizeof(struct sockaddr_in)。

当 connect()函数成功返回时,表示客户端连接服务器成功,此时可以进行后续的数据传送了。

在流式套接字中,connect()函数的调用会触发操作系统完成客户端准备建立连接的过程,该过

程主要包括以下操作:

(1)向套接字注册 name参数中声明的服务器地址。

(2)触发协议栈向函数指明地址的服务器发送 SYN请求,完成 TCP的三次握手。

(3)等待服务器的响应。由于网络传输时延和服务器处理过程中的延迟,connect()函数要等

待协议栈的操作,不一定能立刻返回。工作在阻塞模式下的套接字会一直等待,直到返回连接建立

成功与否;工作在非阻塞模式下的套接字,无论连接是否建立成功都会立即返回,此时需要通过

WSAGetLastError()函数判断当前的连接状态。

另外,在WinSock 2中还增加了WSAConnect()函数来发起连接请求。WSAConnect()函数除了

发起与服务器的连接,还可以交换连接数据,以及根据所提供的流描述确定所需的服务质量。该函

数定义如下:

int WSAConnect (SOCKET s,const struct sockaddr FAR * name,int namelen,

LPWSABUF lpCallerData,LPWSABUF lpCalleeData,LPQOS lpSQOS,LPQOS lpGQOS );

其中:

� s:调用 socket()返回的套接字句柄。

� name:被声明为一个指向 sockaddr结构的指针,该指针指向的结构保存服务器的 IP 地址

和端口号。

� namelen:指明地址结构的长度,通常为 sizeof(struct sockaddr_in)。

Page 35: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 57

� lpCallerData:指向用户数据的指针,该数据在建立连接时将传送到远端。

� lpCalleeData:指向用户数据的指针,该数据在建立连接时将从远端传送回本机。

� lpSQOS:指向套接字流描述的指针,每个方向一个。

� lpGQOS:指向套接字组流描述的指针。

2. 服务器处理连接的请求

服务器进入监听状态后,会将客户端请求进行排队等待处理。此时服务器需要调用 accept()函

数,该函数从连接请求队列中返回一个需要连接的客户端请求。该函数定义如下:

SOCKET accept(SOCKET s, struct sockaddr FAR *addr, int FAR *addrlen);

其中:

� s:已设置为“监听”状态的套接字。我们称它为监听套接字,该套接字只是负责监听连

接请求,实际上不会用于发送和接收数据。

� addr:被声明为一个指向 sockaddr结构的指针。当 accept()函数成功返回后,连接对等方

(客户端)的端点地址被写入到 addr指针指向的结构中一同返回。

� addrlen:指明地址结构的长度。

accept()函数的返回值是另一个用于数据传输的套接字,被称为已连接套接字,负责与本次连

接的客户端通信。区分这两个套接字非常重要。一个服务器通常仅创建一个监听套接字,它在该服

务器的生命期中一直存在。而操作系统为每个由服务器接受的客户端连接创建一个已连接套接字,

当服务器完成对某个给定客户端的服务时,相应的已连接套接字就可以被关闭。

accept()函数的调用实际上是一个出队的操作。如果此时没有客户连接请求,则客户连接请求

队列为空,在阻塞模式下该函数会持续等待,直到一个连接请求到达。当成功返回时,由 addr 指

向的 sockaddr结构中保存了连接对等方(客户端)的 IP地址和端口号,如果服务器对此不关心,

可以将参数 addr和 addrlen设置为 NULL。

另外,在WinSock 2中还增加了WSAAccept()函数来处理连接请求,WSAAccept()函数只是在

accept()函数基础上添加了条件函数来判断是否接受客户端连接。该函数定义如下:

SOCKET WSAAccept (SOCKET s, struct sockaddr FAR * addr, LPINT addrlen,

LPCONDITIONPROC lpfnCondition, DWORD dwCallbackData);

其中:

� s:已设置为“监听”状态的套接字。

� addr:被声明为一个指向 sockaddr结构的指针。当 accept()函数成功返回后,连接对等方

(客户端)的端点地址被写入到 addr指针指向的结构中一同返回。

� addrlen:指明地址结构的长度。

� lpfnCondition:用户提供的条件函数的进程实例地址,该函数根据参数传入的调用者信息

作出接受或拒绝的决定。

� dwCallbackData:作为条件函数参数返回给应用程序的回调数据。

Page 36: 第2章 - tup.com.cn

58 | 网络程序设计应用教程

3.3.5 数据通信

套接字连接成功后,客户端和服务器之间就可以进行网络通信了,可以开始发送和接收数据,

此操作对于服务器和客户端来说是没有区别的。

1. 发送数据

流式套接字通常使用 send()函数进行数据的发送。该函数的定义如下:

int send (SOCKET s,const char FAR *buf, int len, int flags);

其中:

� s:已连接套接字的句柄。数据的发送将会通过它根据其指向的套接字结构,获得数据通

信的对方地址,然后把数据发送出去。

� buf:指向要发送的数据保存的缓冲区。

� len:要发送的数据长度。

� flags:指定调用模式,把 flags 设置为 0 用于指定默认的行为。另外,数据传输还能以

MSG_PEEK(查看当前数据,数据将被复制到缓冲区中,但并不从输入队列中删除)和

MSG_OOB(处理带外数据)两种方式进行。

send()函数的返回值是实际发送的字节总数。

在WinSock 2中增加了WSASend()函数来发送数据,WSASend()函数在 send()函数基础上增加

了对重叠 I/O模式的支持。该函数的定义如下:

int WSASend (SOCKET s,LPWSABUF lpBuffers, DWORD dwBufferCount,

LPDWORD lpNumberOfBytesSent,DWORD dwFlags,LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);

其中:

� s:已连接套接字的句柄。

� lpBuffers:一个指向 WSABUF 结构数组的指针,每个 WSABUF 结构包含缓冲区的指针

和缓冲区的大小。

� dwBufferCount:lpBuffers数组中WSABUF结构的数目。

� lpNumberOfBytesSent:如果发送操作立即完成,则为一个指向所发送数据字节数的指针。

� dwFlags:指定调用模式。

� lpOverlapped:指向WSAOVERLAPPED结构的指针(非重叠 I/O模式忽略)。

� lpCompletionRoutine:一个指向发送操作完成后调用的完成例程的指针(非重叠 I/O模式

忽略)。

2. 接收数据

流式套接字通常使用 recv()函数进行数据的接收。该函数的定义如下:

Page 37: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 59

int recv (SOCKET s, char *buf, int len, int flags );

其中:

� s:已连接套接字的句柄。通过套接字指向的套接字结构所标识的端点地址,TCP协议会

将发送给本地端点地址的数据提交到该套接字的接收缓冲区中。

� buf:指向要保存接收数据的缓冲区。

� len:接收缓冲区的大小。

� flags:指定调用模式,把 flags 设置为 0 用于指定默认的行为。另外,数据传输还能以

MSG_PEEK(查看当前数据,数据将被复制到缓冲区中,但并不从输入队列中删除)和

MSG_OOB(处理带外数据)两种方式进行。

recv()函数的返回值是实际接收的字节总数。

在WinSock 2中增加了WSARecv()函数来发送数据,WSARecv()函数在 recv()函数基础上增加

了对重叠 I/O模式的支持。该函数的定义如下:

int WSARecv (SOCKET s,LPWSABUF lpBuffers, DWORD dwBufferCount,

LPDWORD lpNumberOfBytesSent,LPDWORD lpFlags,LPWSAOVERLAPPED lpOverlapped,

LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);

其中:

� s:已连接套接字的句柄。

� lpBuffers:一个指向 WSABUF 结构数组的指针,每个 WSABUF 结构包含缓冲区的指针

和缓冲区的大小。

� dwBufferCount:lpBuffers数组中WSABUF结构的数目。

� lpNumberOfBytesSent:如果发送操作立即完成,则为一个指向所发送数据字节数的指针。

� lpFlags:一个指向调用模式的指针。

� lpOverlapped:指向WSAOVERLAPPED结构的指针(非重叠 I/O模式忽略)。

� lpCompletionRoutine:一个指向发送操作完成后调用的完成例程的指针(非重叠 I/O模式

忽略)。

3.4 阻塞流式套接字编程实例

本节介绍在Windows控制台环境下实现进行消息收发的网络应用程序。

3.4.1 问题

复杂度:3星。

需求:两台计算机通过网络进行一对一消息传送。

Page 38: 第2章 - tup.com.cn

60 | 网络程序设计应用教程

3.4.2 实现方法

应用程序要求通过网络进行一对一消息传送,为了简化实现,因此采用基于阻塞流式套接字的

编程模式。本节分别设计服务器和客户端两个独立的网络应用程序,结合前面几节的内容说明流式

套接字编程中各函数的使用方法。

3.4.3 基于阻塞流式套接字的服务器实现

首先实现服务器应用程序。下面列出了服务器的全部实现的代码,其中添加注释来说明实现的

一些细节:

#include "stdafx.h"

//包含 WinSock 2的头文件

#include <winsock2.h>

#include <stdio.h>

//连接 WinSock 2的库文件

#pragma comment(lib, "ws2_32.LIB")

//定义缓冲区大小

#define BUFFER_LEN 1024

//控制台应用程序主入口

int _tmain(int argc, char* argv[])

{

//初始化 WinSock 2动态库

WSADATA wsa_data;

WORD winsock_ver = MAKEWORD(2, 0);

int ret = WSAStartup(winsock_ver, &wsa_data);

//初始化失败则退出

if( ret != 0 ) return -1;

//创建套接字

SOCKET sock;

//创建基于 IPv4地址族(AF_INET)的流式套接字(SOCK_STREAM),协议类型为 TCP

sock= socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

//创建失败则退出

if(sock == INVALID_SOCKET) return -1;

//绑定服务器地址(端口号 7001)

int port = 7001;

int addr_len = sizeof(SOCKADDR_IN);

//设置服务器端点地址,采用 IPv4地址

SOCKADDR_IN addr_svr;

addr_svr.sin_family = AF_INET;

//从主机字节顺序转变成网络字节顺序

addr_svr.sin_port = htons(port);

Page 39: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 61

//INADDR_ANY表示绑定本计算机所有 IP地址

addr_svr.sin_addr.S_un.S_addr = INADDR_ANY;

//调用 bind()绑定地址

ret = bind(sock, (SOCKADDR*)&addr_svr, addr_len);

//绑定失败则关闭套接字并退出

if (ret != 0)

{

closesocket(sock);

return -1;

}

//启动服务器监听,SOMAXCONN是系统定义的每个端口最大的监听队列长度

ret = listen(sock, SOMAXCONN);

//启动监听失败则关闭套接字并退出

if (ret == SOCKET_ERROR)

{

closesocket(sock);

return -1;

}

//用来保存和服务器连接的客户端的端点地址

SOCKADDR_IN addr_clt;

//处理客户端的连接请求,并获得和客户端连接的套接字

SOCKET sock_clt = accept(sock, (SOCKADDR*)&addr_clt, &addr_len);

//若和客户端连接的套接字错误,则关闭服务器套接字并退出

if (sock_clt == INVALID_SOCKET)

{

closesocket(sock);

return -1;

}

//定义消息传送需要的缓冲区

char recvbuf[BUFFER_LEN];

char sendbuf[BUFFER_LEN];

int numRead;

//接收客户端消息并显示,从键盘输入消息发送给客户端

while( 1 )

{

//清空接收缓冲区

memset(recvbuf, 0, BUFFER_LEN );

//等待接收客户端的数据

numRead = recv(sock_clt, recvbuf, BUFFER_LEN, 0);

//正确接收到客户端消息,numRead是接收到数据的字节数

if( numRead> 0 )

{

//显示接收到的客户端消息

Page 40: 第2章 - tup.com.cn

62 | 网络程序设计应用教程

printf("%s\n", recvbuf);

//接收到“quit”则退出消息接收发送

if( strcmp(recvbuf, "quit") == 0 )

break;

//从键盘输入消息

gets(sendbuf);

//将消息发送给客户端

send(sock_clt, sendbuf, strlen(sendbuf), 0);

}

}

//关闭和客户端连接的套接字

closesocket(sock_clt);

//关闭服务器套接字

closesocket(sock);

//释放 WinSock 2动态库

WSACleanup();

return 0;

}

因为 socket()函数在默认情况下创建的套接字是阻塞模式,而且创建后代码里也没调用

ioctlsocket()和WSAIoctl()函数设置控制命令来修改 I/O模式,所以上面代码实现的服务器应用程序

工作在阻塞模式。阻塞模式在程序运行时体现在两个肯定阻塞的地方和一个可能阻塞的地方:

(1)调用 accept()函数处理客户端连接时肯定会进入阻塞(等待)状态,直到有客户端请求连

接才继续往下执行。

(2)调用 recv()函数接收客户端数据时肯定会进入阻塞(等待)状态,直到读到客户端发送

来的数据后才继续往下执行。

(3)调用 send()函数给客户端发送数据时可能会进入阻塞(等待)状态。如果套接字发送缓

冲区满了,就要等缓冲区腾出足够的空间后才继续往下执行,否则不用阻塞(等待)。

3.4.4 基于阻塞流式套接字的客户端实现

下面列出了客户端的全部实现的代码,其中添加注释来说明实现的一些细节:

#include "stdafx.h"

//包含 WinSock 2的头文件

#include <winsock2.h>

#include <stdio.h>

//连接 WinSock 2的库文件

#pragma comment(lib, "ws2_32.LIB")

//定义缓冲区大小

#define BUFFER_LEN 1024

//控制台应用程序主入口

Page 41: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 63

int _tmain(int argc, char* argv[])

{

//初始化 WinSock 2动态库

WSADATA wsa_data;

WORD winsock_ver = MAKEWORD(2, 0);

int ret = WSAStartup(winsock_ver, &wsa_data);

//初始化失败则退出

if( ret != 0 ) return -1;

//创建套接字

SOCKET sock;

//创建基于 IPv4地址族(AF_INET)的流式套接字(SOCK_STREAM),协议类型为 TCP

sock= socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

//创建失败则退出

if(sock == INVALID_SOCKET) return -1;

//设置服务器端点地址(IP地址+端口号 7001)

char strIP[] = "192.168.0.100";

int port = 7001;

int addr_len = sizeof(SOCKADDR_IN);

SOCKADDR_IN addr_svr;

addr_svr.sin_family = AF_INET;

//从主机字节顺序转变成网络字节顺序

addr_svr.sin_port = htons(port);

//把点分字符形式的 IP地址转换成无符号长整数形式

addr_svr.sin_addr.S_un.S_addr = inet_addr(strIP);

//连接服务器,端点地址 addr_svr确定了具体的计算机及服务器应用进程

ret = connect(sock, (struct sockaddr*)&addr_svr, addr_len);

//连接服务器失败则关闭套接字并退出

if (ret == INVALID_SOCKET)

{

closesocket(sock);

return -1;

}

//定义消息传送需要的缓冲区

char recvbuf[BUFFER_LEN];

char sendbuf[BUFFER_LEN];

int numRead;

//从键盘输入消息发送给服务器,然后接收服务器回应消息并显示

while( 1 )

{

//从键盘输入消息

gets(sendbuf);

//将消息发送给服务器

send(sock, sendbuf, strlen(sendbuf), 0);

Page 42: 第2章 - tup.com.cn

64 | 网络程序设计应用教程

//输入“quit”后则退出消息接收发送

if( strcmp(sendbuf, "quit") == 0 )

break;

//清空接收缓冲区

memset(recvbuf, 0, BUFFER_LEN );

//等待接收服务器的数据

numRead = recv(sock_clt, recvbuf, BUFFER_LEN, 0);

//正确接收到服务器消息,numRead是接收到数据的字节数

if( numRead> 0 )

{

//显示接收到的服务器消息

}

//关闭客户端套接字

closesocket(sock);

//释放 WinSock 2动态库

WSACleanup();

return 0;

同样,客户端用 socket()函数在默认情况下创建了阻塞模式的套接字。客户端阻塞模式在程序

运行时体现在如下两方面:

(1)调用 recv()函数接收客户端数据肯定会进入阻塞(等待)状态,直到读到服务器发送来

的数据后才继续往下执行。

(2)调用 send()函数给客户端发送数据时可能会进入阻塞(等待)状态。如果套接字发送缓

冲区满了,就要等缓冲区腾出足够的空间后才继续往下执行,否则不用阻塞(等待)。

上面给出的例子是最简单的基于阻塞流式套接字实现的网络应用程序,分析代码就可以发现它

的局限性。首先,服务器不能同时支持多个客户端,只能和一个客户端连接;其次,消息的发送和

接收严格遵守一收一发顺序传送数据。

从以上实例也可以发现,阻塞模式的流式套接字编程比较简单方便,运行稳定,但性能上没有

优势。下节对同样的需求采用非阻塞流式套接字编程来实现,看看两者的区别。

3.5 非阻塞流式套接字编程实例

本节介绍在 Windows 控制台环境下实现进行消息收发的网络应用程序。

3.5.1 问题

复杂度:3星。

Page 43: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 65

需求:两台计算机通过网络进行一对一消息传送。

3.5.2 实现方法

应用程序要求通过网络进行一对一消息传送,考虑实现的应用程序有更好的性能,因此采用基

于非阻塞流式套接字的编程模式。本节分别设计服务器和客户端两个独立的网络应用程序,结合前

面章节的内容说明流式套接字编程中各函数的使用方法。

3.5.3 基于非阻塞流式套接字的服务器实现

首先实现服务器应用程序。下面列出了服务器的全部实现的代码,其中添加注释来说明实现的

一些细节:

#include "stdafx.h"

//包含 WinSock 2的头文件

#include <winsock2.h>

#include <stdio.h>

//连接 WinSock 2的库文件

#pragma comment(lib, "ws2_32.LIB")

//定义缓冲区大小

#define BUFFER_LEN 1024

//控制台应用程序主入口

int _tmain(int argc, char* argv[])

{

//初始化 WinSock 2动态库

WSADATA wsa_data;

WORD winsock_ver = MAKEWORD(2, 0);

int ret = WSAStartup(winsock_ver, &wsa_data);

//初始化失败则退出

if( ret != 0 ) return -1;

//创建套接字

SOCKET sock;

//创建基于 IPv4地址族(AF_INET)的流式套接字(SOCK_STREAM),协议类型为 TCP

sock= socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

//创建失败则退出

if(sock == INVALID_SOCKET) return -1;

//绑定服务器地址(端口号 7001)

int port = 7001;

int addr_len = sizeof(SOCKADDR_IN);

//设置服务器端点地址,采用 IPv4地址

SOCKADDR_IN addr_svr;

addr_svr.sin_family = AF_INET;

Page 44: 第2章 - tup.com.cn

66 | 网络程序设计应用教程

//从主机字节顺序转变成网络字节顺序

addr_svr.sin_port = htons(port);

//INADDR_ANY表示绑定本计算机所有 IP地址

addr_svr.sin_addr.S_un.S_addr = INADDR_ANY;

//调用 bind()绑定地址

ret = bind(sock, (SOCKADDR*)&addr_svr, addr_len);

//绑定失败则关闭套接字并退出

if (ret != 0)

{

closesocket(sock);

return -1;

}

//设置为非阻塞模式, 控制命令为 FIONBIO时,第三个参数设置为非 0值,

//则表示此套接字被设置为非阻塞模式

int nMode = 1;

ret = ioctlsocket(sock, FIONBIO, (ULONG *)&nMode);

//设置非阻塞模式失败则关闭套接字并退出

if (ret == SOCKET_ERROR)

{

closesocket(sock);

return -1;

}

//启动服务器监听,SOMAXCONN是系统定义的每个端口最大的监听队列长度

ret = listen(sock, SOMAXCONN);

//启动监听失败则关闭套接字并退出

if (ret == SOCKET_ERROR)

{

closesocket(sock);

return -1;

}

//用来保存和服务器连接的客户端的端点地址

SOCKADDR_IN addr_clt;

//处理客户端的连接请求,并获得和客户端连接的套接字

SOCKET sock_clt = accept(sock, (SOCKADDR*)&addr_clt, &addr_len);

//和客户端连接的套接字错误则关闭服务器套接字并退出

if (sock_clt == INVALID_SOCKET)

{

closesocket(sock);

return -1;

}

//用来保存和服务器连接的客户端的端点地址和连接客户端的套接字

SOCKADDR_IN addr_clt;

SOCKET sock_clt;

Page 45: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 67

//非阻塞模式处理客户端的连接请求

while ( 1 )

{

sock_clt = accept(m_Socket, (SOCKADDR*)&addr_clt, &addr_len);

//返回无效的套接字

if (sock_clt == INVALID_SOCKET)

{

//获得错误状态

DWORD dwErr = WSAGetLastError();

//在非阻塞模式下 WSAEWOULDBLOCK错误状态表示没有连接请求

if( dwErr == WSAEWOULDBLOCK )

{

//休眠 1000毫秒后再继续调用 accept()函数处理连接请求

//这样做的好处是此循环不会独占程序资源

Sleep(1000);

}

//其他错误状态则关闭服务器套接字并退出

else

{

closesocket(sock);

return -1;

}

}

//和客户端的连接成功,并获得和客户端连接的套接字,退出循环

break;

}

//定义消息传送需要的缓冲区

char recvbuf[BUFFER_LEN];

char sendbuf[BUFFER_LEN];

int numRead;

//清空接收缓冲区,等待接收数据

memset(recvbuf, 0, BUFFER_LEN );

//接收客户端消息并显示,从键盘输入消息发送给客户端

while( 1 )

{

//等待接收客户端的数据

numRead = recv(sock_clt, recvbuf, BUFFER_LEN, 0);

//正确接收到客户端消息,numRead是接收到数据的字节数

if( numRead> 0 )

{

//显示接收到的客户端消息

printf("%s\n", recvbuf);

//接收到“quit”则退出消息接收发送

Page 46: 第2章 - tup.com.cn

68 | 网络程序设计应用教程

if( strcmp(recvbuf, "quit") == 0 )

break;

//从键盘输入消息

gets(sendbuf);

//将消息发送给客户端

ret = send(sock_clt, sendbuf, strlen(sendbuf), 0);

//发送消息给客户端错误则退出循环结束程序

if( ret == SOCKET_ERROR ) break;

//清空接收缓冲区,等待接收数据

memset(recvbuf, 0, BUFFER_LEN );

}

//没有接收到客户端消息,休眠 5毫秒

else Sleep(5);

}

//关闭和客户端连接的套接字

closesocket(sock_clt);

//关闭服务器套接字

closesocket(sock);

//释放 WinSock 2动态库

WSACleanup();

return 0;

}

socket()函数在默认情况下创建的套接字是阻塞模式,然后调用 ioctlsocket()函数设置控制命令

FIONBIO 来修改为非阻塞模式,所以上面代码实现的服务器应用程序工作在非阻塞模式,所有套

接字函数都会在调用后直接返回结果。为了通过 accept()函数能够获得和客户端的连接,需要使用

循环直到连接客户端成功。当没有客户端连接时返回 WSAEWOULDBLOCK错误状态,程序休眠

一段时间可以避免这个循环不会独占程序资源,否则会和阻塞情况相似。本实例因为实现较简单,

体现不出这点的好处。

另外,recv()函数也不会阻塞,直接返回结果。如果接收数据成功则返回接收到的字节数,没

有可接收的数据或其他错误则返回 SOCKET_ERROR。

同样,send()函数也不会阻塞,直接返回结果。如果发送数据成功则返回发送的字节数,发送

错误则返回 SOCKET_ERROR。

要特别说明的是,WSAEWOULDBLOCK 错误在非阻塞模式下是有特定意义的。比如,调用

accept()函数时一般表示此时没有连接请求,调用 recv()函数时一般表示此时套接字缓冲区里没有可

接收的数据,调用 send()函数时一般表示套接字缓冲区里没有足够的空间给发送数据,一般此时会

重新调用相关函数再执行操作。

3.5.4 基于非阻塞流式套接字的客户端实现

下面列出客户端的全部实现的代码,其中添加注释来说明实现的一些细节:

Page 47: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 69

#include "stdafx.h"

//包含 WinSock 2的头文件

#include <winsock2.h>

#include <stdio.h>

//连接 WinSock 2的库文件

#pragma comment(lib, "ws2_32.LIB")

//定义缓冲区大小

#define BUFFER_LEN 1024

//控制台应用程序主入口

int _tmain(int argc, char* argv[])

{

//初始化 WinSock 2动态库

WSADATA wsa_data;

WORD winsock_ver = MAKEWORD(2, 0);

int ret = WSAStartup(winsock_ver, &wsa_data);

//初始化失败则退出

if( ret != 0 ) return -1;

//创建套接字

SOCKET sock;

//创建基于 IPv4地址族(AF_INET)的流式套接字(SOCK_STREAM),协议类型为 TCP

sock= socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

//创建失败则退出

if(sock == INVALID_SOCKET) return -1;

//设置服务器端点地址(IP地址+端口号 7001)

char strIP[] = "192.168.0.100";

int port = 7001;

int addr_len = sizeof(SOCKADDR_IN);

SOCKADDR_IN addr_svr;

addr_svr.sin_family = AF_INET;

//从主机字节顺序转变成网络字节顺序

addr_svr.sin_port = htons(port);

//把点分字符形式的 IP地址转换成无符号长整数形式

addr_svr.sin_addr.S_un.S_addr = inet_addr(strIP);

//连接服务器,端点地址 addr_svr确定了具体的计算机及服务器应用进程

ret = connect(sock, (struct sockaddr*)&addr_svr, addr_len);

//连接服务器失败则关闭套接字并退出

if (ret == INVALID_SOCKET)

{

closesocket(sock);

return -1;

}

//设置为非阻塞模式, 控制命令为 FIONBIO时,第 3个参数设置为非 0值,

//则表示此套接字被设置为非阻塞模式

Page 48: 第2章 - tup.com.cn

70 | 网络程序设计应用教程

int nMode = 1;

ret = ioctlsocket(sock, FIONBIO, (ULONG *)&nMode);

//设置非阻塞模式失败则关闭套接字并退出

if (ret == SOCKET_ERROR)

{

closesocket(sock);

return -1;

}

//定义消息传送需要的缓冲区

char recvbuf[BUFFER_LEN];

char sendbuf[BUFFER_LEN];

int numRead;

//从键盘输入消息发送给服务器,然后接收服务器回应消息并显示

while( 1 )

{

//从键盘输入消息

gets(sendbuf);

//将消息发送给服务器

send(sock, sendbuf, strlen(sendbuf), 0);

//输入“quit”后则退出消息接收发送

if( strcmp(sendbuf, "quit") == 0 )

break;

//清空接收缓冲区

memset(recvbuf, 0, BUFFER_LEN );

//等待接收服务器的数据

while( 1 )

{

numRead = recv(m_socket, recvbuf, 1024, 0);

//正确接收到服务器消息,numRead是接收到数据的字节数

if(numRead> 0 )

{

//显示接收到的服务器消息并退出循环

printf("%s\n", recvbuf);

break;

}

//没有接收到服务器消息,休眠 5毫秒

else Sleep(5);

}

}

//关闭客户端套接字

closesocket(sock);

//释放 WinSock 2动态库

WSACleanup();

Page 49: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 71

return 0;

socket()函数在默认情况下创建的套接字是阻塞模式,然后调用 ioctlsocket()函数设置控制命令

FIONBIO 来修改为非阻塞模式,所以上面代码实现的客户端应用程序工作在非阻塞模式,所有套

接字函数都会在调用后直接返回结果。

因此,recv()函数不会阻塞,直接返回结果。如果接收数据成功则返回接收到的字节数,没有

可接收的数据或其他错误则返回 SOCKET_ERROR,因此要保证接收到服务器的回应消息才执行下

一步。

同样,send()函数也不会阻塞,直接返回结果。如果发送数据成功则返回发送的字节数,发送

错误则返回 SOCKET_ERROR。

上面给出的例子是最简单的基于非阻塞流式套接字实现的网络应用程序,分析代码就可以发现

它的局限性和前面的基于阻塞流式套接字实现的网络应用程序一样。首先,服务器不能同时支持多

个客户端,只能和一个客户端连接;其次,消息的发送和接收严格遵守一收一发的顺序。

从以上实例也可以发现,非阻塞模式的流式套接字编程比较复杂,运行可能会出现一些不好控

制的问题,但性能有较大优势。

3.6 Windows下的多线程编程

分析上面例子的代码可以看出,这两个实例的服务器都只能完成和单客户端的网络通信。

为了能够完成服务器和多个客户端的连接以及和这些客户端进行可靠的消息发送,我们需要使

用多线程方式来实现。首先,我们介绍Windows下多线程编程的基本知识。

Windows是一种多任务的操作系统,在Windows的一个进程内包含一个或多个线程。Windows

环境下的 Win32 API 提供了多线程应用程序开发所需要的接口函数集。多线程编程下,进程的主

线程在任何需要的时候都可以创建新的线程。当线程执行完后,自动终止线程;当进程结束后,所

有的线程都终止。所有活动的线程共享进程的资源,因此在编程时需要考虑在多个线程访问同一资

源时产生冲突的问题。当一个线程正在访问某个进程对象,而另一个线程要改变该对象,就可能会

产生错误的结果,编程时要解决这个冲突。

3.6.1 线程的创建和终止

Win32函数库中提供了操作多线程的函数,包括创建线程、终止线程、建立互斥区等。

在应用程序的主线程或者其他活动线程中创建新的线程的函数为 CreatThread(),其定义如下:

HANDLECreateThread(

LPSECURITY_ATTRIBUTES lpThreadAttributes,

DWORD dwStackSize,

LPTHREAD_START_ROUTINE lpStartAddress,

Page 50: 第2章 - tup.com.cn

72 | 网络程序设计应用教程

LPVOID lpParameter,

DWORD dwCreationFlags,

LPDWORD lpThreadId

);

其中:

� lpThreadAttributes:表示线程内核对象的安全属性,一般传入 NULL表示使用默认设置。

� dwStackSize: 表示线程栈空间大小,传入 0表示使用默认大小(1MB)。

� lpStartAddress:指向线程函数地址的指针,一般是传入线程函数名。线程函数定义格式

必须为 DWORD WINAPI 函数名 (LPVOID lpParam) 。

� lpParameter:指向传给线程函数的参数指针,就是上面的 lpParam,该参数可以是各种类

型。

� dwCreationFlags:控制线程创建的标志,为 0 表示创建后立即就可以执行,如果为

CREATE_SUSPENDED则表示创建后挂起不执行,直到调用 ResumeThread()。

� lpThreadId: 获得返回线程的 ID号,传入 NULL表示不需要返回该线程 ID号。

如果创建成功则返回线程的句柄,否则返回 NULL。创建了新的线程后,该线程就开始启动执

行并进入线程函数。但如果在 dwCreationFlags中使用了 CREATE_SUSPENDED特性,那么线程并

不马上执行,而是先挂起,等到调用 ResumeThread后才开始启动线程。当调用的线程函数返回后,

线程自动终止。如果需要在线程的执行过程中终止,则可调用函数 VOID ExitThread

(DWORD dwExitCode),这时同样可以让线程结束了。

3.6.2 线程的同步

在线程体内,如果该线程完全独立,与其他线程没有数据存取等资源操作上的冲突,则可按照

通常单线程的方法进行编程。但在多线程处理时情况常常不是这样,线程之间经常要同时访问一些

资源。由于对共享资源进行访问引起冲突是不可避免的,为了解决这种线程同步问题,Win32 API

提供了多种同步控制对象来帮助程序员解决共享资源访问冲突。

在介绍这些同步对象之前先介绍一下等待函数,因为所有控制对象的访问控制都要用到这个函

数。Win32 API提供了一组能使线程阻塞其自身执行的等待函数,这些函数在通过其参数设置的一

个或多个同步对象产生了信号后,或者超过规定的等待时间才会返回。在等待函数未返回时,线程

都处于等待状态,此时线程只消耗很少的 CPU时间。使用等待函数既可以保证线程的同步,又可

以提高程序的运行效率。

最常用的等待函数是 WaitForSingleObject(),该函数通过检测设置对象的信号状态来决定线程

是否等待。在某线程中如果调用该函数,该线程暂时挂起进入等待状态。如果在设置的超时时间内

线程所设置的对象变为有信号状态,等待函数立即结束并返回线程;如果超时时间已经到达但所设

置的对象还没有变成有信号状态,等待函数照样结束并返回线程。该函数的定义如下:

DWORD WaitForSingleObject(HANDLEhHandle,DWORDdwMilliseconds);

Page 51: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 73

其中:

� hHandle:对象句柄。可以指定对象有事件(Event)、信号(Semaphore)、互斥(Mutex)、

临界区(Critical Section)和线程(Thread)等。

� dwMilliseconds:超时时间(毫秒)。如果为非 0值,当时间到了而 hHandle标记的对象

没被触发信号,该等待函数结束等待;如果为 0,等待函数立即返回;如果为 INFINITE,

则直到对象变成有信号后等待函数才会结束并返回。

返回值为 WAIT_OBJECT_0表示设置的对象变为有信号状态,返回值为 WAIT_TIMEOUT表

示超时时间到而设置的对象没有变成信号状态。

函数WaitForMultipleObject()可以用来同时监测多个同步对象。该函数的声明为:

DWORD WaitForMultipleObject(DWORDnCount, CONSTHANDLE*lpHandles,

BOOLbWaitAll, DWORDdwMilliseconds);

其中:

� nCount:需要检测的对象数量。

� lpHandles:指向对象数组的指针,数组大小由 nCount决定。

� bWaitAll:为 True时,表示所有对象都变成有信号状态后,等待函数结束等待,为 False

时,表示其中有一个对象变成有信号状态后,该等待函数结束等待。

� dwMilliseconds:同WaitForSingleObject()函数的参数说明。

3.6.3 同步对象

下面介绍常用的几种同步对象。

1. 互斥对象

互斥对象(Mutex)的状态在它不被任何线程拥有时才有信号,而当它被拥有时则无信号。互

斥对象很适合用来协调多个线程对共享资源的互斥访问。可按下列步骤使用该对象。

首先,创建互斥对象,得到互斥对象句柄。创建函数定义如下:

HANDLE CreateMutex(LPSECURITY_ATTRIBUTES lpMutexAttributes,

BOOL bInitialOwner, LPCTSTR lpName);

其中:

� lpMutexAttributes:忽略,必须为 NULL。

� bInitialOwner:初始化互斥对象的所有者,如为 True,则表示创建进程希望立即拥有。

� lpName:指向互斥对象名的指针。

HANDLE hMutex = CreateMutex(NULL, TRUE, NULL);

其次,在线程访问共享资源之前调用 WaitForSingleObject()函数,将互斥对象句柄传给函数,

Page 52: 第2章 - tup.com.cn

74 | 网络程序设计应用教程

请求占用互斥对象。

WaitForSingleObject(hMutex,5000);

最后,共享资源访问结束,释放对互斥对象的占用。

ReleaseMutex(hMutex);

互斥对象在同一时刻只能被一个线程占用。当互斥对象被一个线程占用时,若有另一个线程想

占用它,则必须等到占用的线程释放后才能成功。

2. 事件对象

事件对象(Event)是最简单的同步对象,它包括有信号和无信号两种状态。在线程访问某一

资源或进行某操作之前,需要等待某一事件的发生,这时用事件对象最合适。例如,服务器收到退

出指令后,激活进入等待的主线程来结束程序。

事件对象是用 CreateEvent()函数创建的。该函数定义如下:

HANDLECreateEvent(LPSECURITY_ATTRIBUTESlpEventAttributes,BOOLbManualReset,

BOOLbInitialState,LPCTSTRlpName );

其中:

� lpEventAttributes:忽略,必须为 NULL。

� bManualReset:指定事件对象创建成手动复原还是自动复原。如果是 True,那么必须用

ResetEvent()函数将事件复原到无信号状态;如果为 False,当等待线程被释放以后,系统

将自动将事件状态复原为无信号状态。

� bInitialState:指定事件对象的初始状态。如果为 True,初始状态为有信号状态;否则为

无信号状态。

� lpName:指向事件对象名的指针。

下面代码表示挂起当前线程进入等待状态:

HANDLE hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

WaitForSingleObject(hEvent,INFINITE);

然后调用下面代码可以结束上面的等待状态:

SetEvent(hEvent);

3. 临界区对象

在临界区中异步执行时,它只能在同一进程的线程之间共享资源处理。虽然此时使用其他同步

对象的方法均可达到目的,但使用临界区的方法使同步管理的效率更高。

首先,在进程使用之前调用 InitializeCriticalSection()函数对临界区对象进行初始化。该函数定

义如下:

void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

Page 53: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 75

其次,lpCriticalSection指向定义临界区对象的指针,该临界区对象由结构 CRITICAL_SECTION

定义。不需要考虑结构 CRITICAL_SECTION 的细节,InitializeCriticalSection()函数会对它里面的

内容正确初始化。

当一个线程要使用共享资源前,调用函数 EnterCriticalSection(LPCRITICAL_SECTION)进入临

界区,该线程独占共享资源;调用函数 LeaveCriticalSection(LPCRITICAL_SECTION)让线程退出临

界区,此时该线程释放对共享资源的占用,供其他线程使用。

该临界区对象可以反复使用,但要保证 EnterCriticalSection()和 LeaveCriticalSection()成对使用。

最后,调用 DeleteCriticalSection(LPCRITICAL_SECTION)释放临界区对象,此后该临界区对象

失效不能再使用了。

4. 信号对象

信号对象允许同时对多个线程共享资源进行访问,在创建对象时指定最大可同时访问的线程数。

当一个线程申请访问成功后,信号对象中的计数器减 1;调用 ReleaseSemaphore()函数后,信号对

象中的计数器加 1。其中,计数器值大于或等于 0,但小于或等于创建时指定的最大值。如果一个

应用在创建一个信号对象时将其计数器的初始值设为 0,就阻塞了其他线程,保护了资源。等初始

化完成后,调用 ReleaseSemaphore()函数将其计数器增加至最大值,则可进行正常的存取访问。可

按下列步骤使用该信号对象。

首先,使用 CreateSemaphore()函数创建信号对象。该函数定义如下:

HANDLE CreateSemaphore(LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,

LONG lInitialCount, LONG lMaximumCount, LPCTSTR lpName );

其次:

� lpSemaphoreAttributes:必须为 NULL。

� lInitialCount:设置信号量的初始计数,可设置 0到 lMaximumCount之间的一个值。

� lMaximumCount:设置信号量的最大计数。

� lpName:指定信号对象名的指针。

然后,在线程访问共享资源之前调用 WaitForSingleObject()函数,此时计数器减 1。如果计数

为 0,线程则进入等待状态,否则线程继续。当线程访问完共享资源之后,应调用 ReleaseSemaphore()

函数释放对信号对象的占用,此时计数器加 1。

最后,需要调用 CloseHandle()函数关闭并释放信号对象。

这四种同步对象各有其特点,使用时需要根据具体情况选择合适的同步对象。

学习了以上关于 Windows 下的多线程编程知识,我们就可以编写一些较复杂的网络应用程序

了。

Page 54: 第2章 - tup.com.cn

76 | 网络程序设计应用教程

3.7 基于流式套接字支持多客户端的编程实例

本节介绍在 Windows 控制台环境下实现进行消息收发的网络应用程序。

3.7.1 问题

复杂度:4星。

需求:一台计算机和多台计算机通过网络进行可靠消息传送。

3.7.2 实现方法

应用程序要求通过网络进行可靠的消息传送,为了编程简单且易于控制,因此采用基于阻塞流

式套接字的编程模式。本节主要设计一个可以处理和多个客户端进行消息收发的服务器的网络应用

程序;另外,客户端的收发数据分开,避免因为等待键盘输入而影响接收数据。

为了能够完成服务器和多个客户端的连接以及和这些客户端进行可靠的消息发送,我们需要使

用多线程方式来实现;另外,为了实现客户端能同时收发数据,也需要使用多线程方式来实现。

3.7.3 基于阻塞流式套接字的多线程服务器的实现

首先,实现服务器应用程序。下面列出了服务器的全部实现的代码,其中添加注释来说明实现

的一些细节:

#include "stdafx.h"

//包含 WinSock 2的头文件

#include <winsock2.h>

#include <stdio.h>

//连接 WinSock 2的库文件

#pragma comment(lib, "ws2_32.LIB")

//定义缓冲区大小

#define BUFFER_LEN 1024

//客户端连接线程处理函数,用来处理客户端的连接请求

DWORD WINAPI ListenThread(LPVOID p);

//和客户端通信的线程处理函数,同客户端进行数据的收发

DWORD WINAPI ClientThread(LPVOID p);

//事件对象句柄,用来关闭主线程结束程序

HANDLE hEvent;

//当前连接的客户端数量,初始化为 0

int nClientNum = 0;

//控制台应用程序主入口

int _tmain(int argc, char* argv[])

Page 55: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 77

{

//初始化 WinSock 2动态库

WSADATA wsa_data;

WORD winsock_ver = MAKEWORD(2, 0);

int ret = WSAStartup(winsock_ver, &wsa_data);

//初始化失败则退出

if( ret != 0 ) return -1;

//创建套接字

SOCKET sock;

//创建基于 IPv4地址族(AF_INET)的流式套接字(SOCK_STREAM),协议类型为 TCP

sock= socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

//创建失败则退出

if(sock == INVALID_SOCKET) return -1;

//绑定服务器地址(端口号 7001)

int port = 7001;

int addr_len = sizeof(SOCKADDR_IN);

//设置服务器端点地址,采用 IPv4地址

SOCKADDR_IN addr_svr;

addr_svr.sin_family = AF_INET;

//从主机字节顺序转变成网络字节顺序

addr_svr.sin_port = htons(port);

//INADDR_ANY表示绑定本计算机所有 IP地址

addr_svr.sin_addr.S_un.S_addr = INADDR_ANY;

//调用 bind()绑定地址

ret = bind(sock, (SOCKADDR*)&addr_svr, addr_len);

//绑定失败则关闭套接字并退出

if (ret != 0)

{

closesocket(sock);

return -1;

}

//启动服务器监听,SOMAXCONN是系统定义的每个端口最大的监听队列长度

ret = listen(sock, SOMAXCONN);

//启动监听失败则关闭套接字并退出

if (ret == SOCKET_ERROR)

{

closesocket(sock);

return -1;

}

//启动客户端请求处理线程,将服务器监听套接字作为参数传给线程处理函数

HANDLE hThread = CreateThread(NULL, 0, ListenThread, (LPVOID)&sock, 0,

NULL);

::CloseHandle(hThread);

Page 56: 第2章 - tup.com.cn

78 | 网络程序设计应用教程

//创建事件对象

hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

//主线程进入等待状态直到事件状态被设置

WaitForSingleObject(hEvent,INFINITE);

//关闭事件对象

CloseHandle(hEvent);

//关闭服务器套接字

closesocket(sock);

//释放 WinSock 2动态库

WSACleanup();

return 0;

}

//客户端连接请求线程处理函数

DWORD WINAPI ListenThread(LPVOID p)

{

//获得服务器监听套接字

SOCKET SockSvr = *(SOCKET*)(p);

//和客户端连接的套接字

SOCKET sock_clt;

//保存客户端的端点地址

SOCKADDR_IN addr_clt;

int addr_len = sizeof(SOCKADDR_IN);

//处理客户端的连接请求

while ( 1 )

{

//接受客户端的连接请求,并获得和客户端连接的套接字

sock_clt = accept(SockSvr, (SOCKADDR*)&addr_clt, &addr_len);

//和客户端连接的套接字错误则继续等待客户端的连接

if (sock_clt == INVALID_SOCKET)

{

continue;

}

// 客户端连接后启动线程处理消息的收发

HANDLE hThread = CreateThread(NULL,0, ClientThread,(LPVOID)&sock_clt,

0, NULL);

//线程启动失败则关闭和客户端连接的套接字,继续等待

if (hThread == NULL)

{

closesocket(sock_clt);

continue;

}

CloseHandle(hThread);

//当前连接的客户端数量加 1

Page 57: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 79

nClientNum++;

}

return 0;

}

//和客户端通信的线程处理函数

DWORD WINAPI ClientThread(LPVOID p)

{

//和客户端连接的套接字

SOCKET& sock_clt = *(SOCKET*)(p);

//定义消息传送需要的缓冲区

char recvbuf[BUFFER_LEN];

char sendbuf[BUFFER_LEN];

int nNumRead;

//接收客户端消息并显示,从键盘输入消息发送给客户端

while( 1 )

{

//清空接收缓冲区

memset(recvbuf, 0, BUFFER_LEN );

//等待接收客户端的数据

numRead = recv(sock_clt, recvbuf, BUFFER_LEN, 0);

//正确接收到客户端消息,numRead是接收到数据的字节数

if( numRead> 0 )

{

//接收到“quit”则退出消息接收发送

if( strcmp(recvbuf, "quit") == 0 )

break;

//显示接收到的客户端消息

printf("%s\n", recvbuf);

//从键盘输入消息

gets(sendbuf);

//将消息发送给客户端

send(sock_clt, sendbuf, strlen(sendbuf), 0);

}

}

//关闭和客户端连接的套接字

closesocket(sock_clt);

//当前连接的客户端数量减 1

nClientNum--;

//如果当前没有客户端连接,询问退出程序还是继续等待连接

if( nClientNum == 0 )

{

printf("input 'exit' to exit, others to continue\n" );

//从键盘输入选择

Page 58: 第2章 - tup.com.cn

80 | 网络程序设计应用教程

gets(sendbuf);

//如果输入"exit"则设置事件

if( strcmp(sendbuf, "exit") == 0 )

{

//设置事件状态触发等待的主线程事件来结束等待

SetEvent(hEvent);

}

}

return 0;

}

上述代码在服务器进入监听后启动一个子线程来处理客户端的请求(此时主线程通过事件对象

进入等待状态),这样就让处理客户端连接请求的 accept()函数虽然是阻塞方式,但不影响主线程

的执行(比如基于窗口的应用程序不会因为阻塞而影响界面操作)。

另外,当和客户端连接成功后,又会启动一个子线程来处理和每个客户端的通信,这样服务器

和每个客户端的数据收发各自独立工作,互相不冲突。

通过 SetEvent()函数设置事件对象来结束主线程的等待,从而结束应用程序。

3.7.4 基于阻塞流式套接字的多线程客户端的实现

下面列出客户端的全部实现的代码,其中添加注释来说明实现的一些细节:

#include "stdafx.h"

//包含 WinSock 2的头文件

#include <winsock2.h>

#include <stdio.h>

//连接 WinSock 2的库文件

#pragma comment(lib, "ws2_32.LIB")

//定义缓冲区大小

#define BUFFER_LEN 1024

//和服务器通信的线程处理函数,只负责接收数据

DWORD WINAPI RecvThread(LPVOID p);

//和服务器通信的线程处理函数,只负责发送数据

DWORD WINAPI SendThread(LPVOID p);

//事件对象句柄,用来关闭主线程结束程序

HANDLE hEvent;

//控制台应用程序主入口

int _tmain(int argc, char* argv[])

{

//初始化 WinSock 2动态库

WSADATA wsa_data;

WORD winsock_ver = MAKEWORD(2, 0);

int ret = WSAStartup(winsock_ver, &wsa_data);

Page 59: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 81

//初始化失败则退出

if( ret != 0 ) return -1;

//创建套接字

SOCKET sock;

//创建基于 IPv4地址族(AF_INET)的流式套接字(SOCK_STREAM),协议类型为 TCP

sock= socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

//创建失败则退出

if(sock == INVALID_SOCKET) return -1;

//设置服务器端点地址(IP地址+端口号 7001)

char strIP[] = "192.168.0.100";

int port = 7001;

int addr_len = sizeof(SOCKADDR_IN);

SOCKADDR_IN addr_svr;

addr_svr.sin_family = AF_INET;

//从主机字节顺序转变成网络字节顺序

addr_svr.sin_port = htons(port);

//把点分字符形式的 IP地址转换成无符号长整数形式

addr_svr.sin_addr.S_un.S_addr = inet_addr(strIP);

//连接服务器,端点地址 addr_svr确定了具体的计算机及服务器应用进程

ret = connect(sock, (struct sockaddr*)&addr_svr, addr_len);

//连接服务器失败则关闭套接字并退出

if (ret == INVALID_SOCKET)

{

closesocket(sock);

return -1;

}

// 客户端连接后启动一个线程专门处理数据接收

HANDLE hRcvThread = CreateThread(NULL, 0, RecvThread, (LPVOID)&sock, 0,

NULL);

if (hRcvThread == NULL)

{

closesocket(sock);

return 0;

}

CloseHandle(hRcvThread);

// 客户端连接后启动一个线程专门处理数据发送

HANDLE hSndThread = CreateThread(NULL, 0, SendThread, (LPVOID)&sock, 0,

NULL);

if (hSndThread == NULL)

{

closesocket(sock);

return 0;

}

Page 60: 第2章 - tup.com.cn

82 | 网络程序设计应用教程

CloseHandle(hSndThread);

//创建事件对象

hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

//主线程进入等待状态直到事件状态被设置

WaitForSingleObject(hEvent,INFINITE);

//关闭事件对象

CloseHandle(hEvent);

//关闭套接字

closesocket(sock);

//释放 WinSock 2动态库

WSACleanup();

return 0;

}

//给服务器发送数据的线程处理函数,此线程随着主线程的结束而结束

DWORD WINAPI SendThread(LPVOID p)

{

//获得套接字

SOCKET& sock = *(SOCKET*)(p);

//发送缓冲区

char sendbuf[1024];

//从键盘输入消息发送给服务器

while( TRUE )

{

//键盘输入消息

gets(sendbuf);

//发送消息给服务器

send(sock, sendbuf, strlen(sendbuf), 0);

//输入“quit”后退出发送线程

if( strcmp(sendbuf,"quit") == 0 ) break;

}

//设置事件状态触发等待的主线程事件来结束等待

SetEvent(hEvent);

return 0;

}

//给服务器发送数据的线程处理函数

DWORD WINAPI RecvThread(LPVOID p)

{

//获得套接字

SOCKET sock = *(SOCKET*)(p);

//接收缓冲区

char recvbuf[BUFFER_LEN];

int numRead;

//接收服务器发送来的数据

Page 61: 第2章 - tup.com.cn

第 3 章 流式套接字编程 | 83

while( TRUE )

{

//清空接收缓冲区

memset(recvbuf, 0, BUFFER_LEN );

//等待接收客户端的数据

numRead = recv(sock, recvbuf, BUFFER_LEN, 0);

//正确接收到客户端消息,numRead是接收到数据的字节数

if( numRead > 0 )

{

//显示接收到的服务器消息

printf("%s\n", recvbuf);

}

}

return 0;

}

上述代码中,当和服务器连接成功后会启动两个子线程,分别处理服务器的数据接收和数据发

送,这样和服务器的数据收发各自独立工作,互相不冲突,不会因为等待键盘输入而影响接收服务

器发来的数据。

另外,通过 SetEvent()函数设置事件对象来结束主线程的等待,从而结束应用程序。

要注意的是,发送子线程是在键盘输入“quit”后主动结束线程,并设置事件对象结束主线程,

但接收数据子线程是随着创建它的主线程的结束而一起结束的。

以上基于多线程的流式套接字编写的网络应用程序只是最基本的框架,大家可以基于此框架以

及结合多线程编程知识,根据应用需求来实现功能更加复杂的网络应用程序。

3.8 习题

1.TCP流式套接字的通信机制是什么?有什么特点?

2.在基于流式套接字的网络应用程序中,假设客户端以每次 100字节大小发送 5次数据给服

务器,请问服务器会有几次接收操作且每次接收到多少字节的数据?为什么?

3.设计客户端,能够向服务器发送请求,请求内容为学生学号,并从服务器接收返回的学生

信息,显示在控制台上。