2.3.4 创建TCP监听套接字
下面开始展示如何创建TCP监听套接字。
已经描述过不同类型的套接字,遗憾的是,Socket API并没有真正地对它们加以区分。所有套接字都由共同的数据类型int来识别,每一个套接字只是一个整数。Sockets API了解内部的所有运作。
警告:总的来说,使用API所提供的typedef(类型定义)总是比较安全的。在Winsock中,即使使用SOCKET时它是一个int,但它也有可能并不总是int。Winsock API函数专门使用SOCKET,如果将来某一天微软公司决定要将套接字改变为foobar类型,而假设正在使用int类型,那么你就倒霉了。尽管对微软公司来说这么做并不方便,但毕竟以前已经发生过这样的事情。最后的结论是:比起使用特定的数据类型来,使用typedef更安全一些。
Winsock还为我们提供了另一种选择方案,即可以选择使用SOCKET类型定义。如果一直跟踪到头文件,最终会发现SOCKET类型定义是一个int。
暂时先假设套接字是一个整数,在第4章中会介绍如何无缝地将两种API抽象到单一的一个包装器中。
1.创建套接字
创建套接字所使用的代码如下:
int sock = socket( AF_INET, SOCK_STREAM, IPPROTO_TCP );
这一代码调用socket函数,它试图创建一个套接字。第一个参数是地址族(Address Family),用来确定套接字将使用的网络。
第二个参数是套接字类型(Socket Type)。此范例中使用的类型为SOCK_STREAM,意思是它是一个TCP套接字。如果想要使用UDP套接字,则应该使用SOCK_DGRAM(DGRAM的意思是数据报)。
注意:除了IP网络之外,Socket API还可以使用许多网络类型。实际上,几乎从来都不需要使用任何其他类型,因为这些网络大多数不是过时了,就是私人公司所使用。因此,几乎总是使用AF_INET地址族。
最后一个参数是协议(Protocol)。不同的套接字类型可能有若干个相关的协议。例如,最流行的SOCK_STREAM协议是IPPROTO_TCP,本范例中所使用的就是这一协议。最流行的SOCK_DGRAM协议是IPPROTO_UDP和IPPROTO_ICMP。前文中已经提到过,我们主要关注TCP和UDP。
如果函数执行失败,则返回-1;如果函数执行成功,则返回套接字描述符。表2.3列出了如果errno/WSAGetLastError()执行失败,它所包含的各种错误值。
表2.3 socket () 函数的错误值
|
错 误 |
含 义 |
|
ENETDOWN |
网络已经失败并断开 |
|
EAFNOSUPPORT |
不支持指定的地址族 |
|
EINPROGRESS |
对此函数的调用仍在进行中,因此不能完成新的调用 |
|
EMFILE |
没有更多可以使用的套接字描述符 |
|
ENOBUFS |
没有足够的内存可以使用 |
|
EPROTONOSUPPORT |
不支持指定的协议 |
|
EPROTOTYPE |
套接字类型不支持指定的协议 |
|
ESOCKTNOSUPPORT |
地址族不支持指定的套接字类型 |
|
WSAENOTINITIALIZED(注释1) |
Socket库没有初始化 |
注释1:只适用于Winsock。
2.绑定套接字
现在已经拥有了一个套接字了,接下来要将套接字绑定到一个端口号。遗憾的是,它并非像听起来这么简单。首先必须填充一个数据结构。
但是,在此之前,先介绍一下函数定义:
int bind( int socket, struct sockaddr *name, int namelen );
第一个参数是用socket函数创建的套接字描述符。
第二个参数是sockaddr结构,它描述了有关套接字所有类型的特性,其中最重要的是端口号,但也描述了一些其他事情,稍后会作介绍。
最后一个参数是sockaddr结构的大小。为什么需要这一参数呢?从宏观上来说,sockaddr结构实际上并不重要,它是一个很灵活的结构,没有固定的定义。所使用的套接字类型和协议不同,这一结构的大小也有所不同,重要的是要了解此函数的这一特性。
下面是sockaddr结构的标准定义:
struct sockaddr {
unsigned short sa_family;
char sa_data[14];
};
第一条数据是sa_family变量。该变量设置为套接字正在使用的地址族,它几乎总是因特网地址族AF_INET。结构中的其余部分只是为了填充到16字节。
显而易见,这并没有包括很多有关套接字连接的信息,因此它没有那么大的用途。相反,我们将使用一个更具体的版本,称为sockaddr_in,这里in代表因特网。可以认为它是继承了继承性实际上还没有出现之前的旧格式。基础结构是sockaddr,为IP网络设计的更具体的版本是sockaddr_in结构。
新结构看起来类似于下面这样:
struct sockaddr_in {
unsigned short sin_family;
unsigned short sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
第一个变量sin_family与结构sockaddr中的变量sa_family相同。变量sin_port只是套接字将要打开的端口号。
第三个变量sin_addr是IP地址,它有两个作用:第一,如果它正在监听套接字,则套接字使用这一地址来监听;第二,如果它是客户端的数据套接字,则套接字将它用作连接的IP地址(在“创建TCP数据套接字”一节中将会详细介绍这一内容)。
端口和地址都应该遵循网络字节顺序。
最后一个变量sin_zero只是为了将结构填充到16字节。Socket API的有些实现要求必须用零来填充,因此必须这么做。
现在实际上就绑定了一个套接字,首先创建sockaddr_in结构,并对它进行填充:
struct sockaddr_in socketaddress; // 创建结构
socketaddress.sin_family = AF_INET; // 为因特网设置它
socketaddress.sin_port = htons( 1000 ); // 使用端口1000
socketaddress.sin_addr.s_addr = htonl( INADDR_ANY ); // 绑定到任何地址
memset( &(socketaddress.sin_zero), 0, 8 ); // 清除添充
上面将套接字绑定到端口1000和地址INADDR_ANY,这基本上意味着套接字将会接受任何进来的连接。虽然几乎总是使用这个值,但也可以使用其他值。例如,如果使用地址127.0.0.1(这是一个环回地址,用来引用自己的计算机),则套接字只接受试图连接到IP地址127.0.0.1的那些连接。因为从其他计算机发送过来的包将不会试图访问这一地址,所以套接字不会接受来自外部计算机的连接。
提示:如果正在个人网络上运行网络地址转换(Network Address Translation,NAT),则我们的个人局域网(Local Area Network,LAN)中的每一台计算机都有一个属于它自己的IP地址(通常的地址范围是192.168.0.*),但是从个人LAN外来看,因特网认为此LAN中的所有计算机只有一个IP地址。通过指定LAN IP地址,可以阻止此LAN以外的人们访问网络程序。这样的话,套接字只会接受试图到达LAN地址的那些计算机所发起的连接,此LAN地址在因特网上是可见的。图2.8展示了简单的NAT/LAN设置,采用这种设置,则因特网认为连接的计算机只有一个IP地址。隐藏在NAT之后的所有计算机都被分配内部IP地址,因特网访问这些计算机所使用的IP地址是NAT本身的IP地址。因特网并不知道或者并不关心这些内部地址。

图2.8 简单的NAT/LAN设置
Socket API为我们提供了一个函数,可以很方便地将字符串格式的IP地址转换为遵循网络字节顺序的整数。例如,如果想要在sockaddr_in结构中使用IP地址127.0.0.1,则输入下面代码:
socketaddress.sin_addr.s_addr = inet_addr( "127.0.0.1" );
最后将套接字与地址结构绑定起来,因此输入下面的代码:
bind( sock, (struct sockaddr*)&socketaddress, sizeof(struct sockaddr));
如果函数执行不成功,则返回-1;如果函数执行成功,则返回0。表2.4展示了可能的错误代码。
表2.4 bind () 函数的错误代码
|
错 误 |
含 义 |
|
ENETDOWN |
网络已经失败并断开 |
|
EINPROGRESS |
对此函数的调用仍在进行中,因此不能完成新的调用 |
|
ENOBUFS |
没有足够的内存可以使用 |
续表
|
错 误 |
含 义 |
|
ENOTSOCK |
传递过来的套接字描述符不是真正的套接字 |
|
EACCES |
访问被否决 |
|
EADDRINUSE |
地址已经在使用中 |
|
EADDRNOTAVAIL |
地址对这台机器无效 |
|
EFAULT |
一个或多个参数无效 |
|
WSAENOTINITIALIZED(注释1) |
Socket库没有初始化 |
注释1:只适用于Winsock。
现在套接字已被绑定,并做好准备接受连接。
3.监听
现在已经将套接字与地址和端口进行了绑定,还需要让它来监听连接。所幸的是,这一函数极其简单:
int listen( int socket, int backlog );
此函数接受套接字描述符和backlog参数。backlog参数的作用实质上是告诉套接字队列中的连接数达到多少时就开始拒绝这些连接。保留在套接字队列中的连接一直到使用accept()函数时才能删除。
下面是调用此函数的一个范例:
listen( sock, 16 );
它告诉Socket API想要监听套接字,而且队列中的最大连接数为16。如果在能够接受这些连接之前,第17台机器试图要连接到这一套接字,则第17个连接被拒绝,而最初的16个连接仍然在队列中,直到接受这些连接。无论什么时候,只要接受一个连接,则它就从队列中被删除掉,然后就可以有更多的连接排队等候。
如果函数执行不产生错误,则返回0;如果函数执行产生错误,则返回-1。表2.5列出了这一函数的错误代码。
表2.5 listen () 函数的错误代码
|
错 误 |
含 义 |
|
ENETDOWN |
网络已经失败并断开 |
|
EADDRINUSE |
地址已经在使用中 |
|
EINPROGRESS |
对此函数的调用仍在进行中,因此不能完成新的调用 |
|
EINVAL |
套接字无效 |
|
EISCONN |
套接字已经被连接 |
|
EMFILE |
没有更多可以使用的套接字描述符 |
|
ENOBUFS |
没有足够的内存可以使用 |
续表
|
错 误 |
含 义 |
|
ENOTSOCK |
传递过来的套接字描述符不是真正的套接字 |
|
EOPNOTSUPP |
套接字不支持这一函数 |
|
WSAENOTINITIALIZED(注释1) |
Socket库没有初始化 |
注释1:只适用于Winsock。
现在套接字正在监听,已做好准备接受连接。
4.接受连接
监听套接字的最后一部分要讲的是接受连接。函数原型如下:
int accept( int socket, struct sockaddr *addr, socklen_t *addrlen );
此函数有3个参数:监听套接字描述符、sockaddr类型的指针以及int类型的指针。
sockaddr结构由函数来填充,可以将它想象成一个来电显示盒,它可以表明是谁正在与我们连接。addrlen指针应该包含addr结构的长度。为什么它是一个指针呢?推测起来,是因为函数有可能会修改此值,但是还从未曾见过这样的事发生。这只是API难以预料的事情之一。下面给出了如何接受这一函数:
int datasock;
struct sockaddr_in socketaddress;
socklen_t sa_size = sizeof( struct sockaddr_in );
datasock = accept(sock, &socketaddress, &sa_size);
现在变量datasock应该是一个数据套接字,可以用它与调用者进行通信。如果函数执行失败,则返回-1。表2.6列出了可能的错误代码。
表2.6 accept () 函数的错误代码
|
错 误 |
含 义 |
|
ENETDOWN |
网络已经失败并断开 |
|
EINPROGRESS |
对此函数的调用仍在进行中,因此不能完成新的调用 |
|
EINVAL |
套接字无效 |
|
EMFILE |
没有更多可以使用的套接字描述符 |
|
ENOBUFS |
没有足够的内存可以使用 |
|
ENOTSOCK |
传递过来的套接字描述符不是真正的套接字 |
|
EOPNOTSUPP |
套接字不支持这一函数 |
|
EFAULT |
一个或多个参数无效 |
|
EWOULDBLOCK |
函数由于阻塞而退出 |
|
WSAENOTINITIALIZED(注释1) |
Socket库没有初始化 |
注释1:只适用于Winsock。
此调用到目前为止,已经介绍过的其他任何调用所不同的一个相当重要的方面就是它会阻塞。看看下面有关阻塞的补充内容。
阻塞函数
对阻塞(blocking)这一术语还不熟悉也没有关系。如果以前曾使用过cin或scanf函数,则实际上已经遇到过阻塞了。
阻塞函数依赖于外部输入(键盘或者网络),在收到输入之前函数不可能完成。遗憾的是,这些输入源并不可靠。如果用户不在那里输入信息,键盘输入就要花费很长时间,网络通信也要花时间。但是,阻塞函数会使整个程序停止执行,并等待这些外部数据到达。
以前,这种行为是大家所期望的。一个系统上有许多程序在运行,无论何时,只要一个程序需要从速度可能很慢的输入源中输入数据,就会停止此程序,并在等待输入期间切换到其他程序。
但是,对于游戏来说,这一想法并不太理想。没有人愿意将整个游戏停下来等待网络或者从键盘输入数据,这会让人感到很麻烦。幸运的是,还是有一些解决这一问题的方法。我们可以让套接字不阻塞,这就意味着如果队列中的数据还不能够使用,则任何阻塞函数都将失败,并返回一个EWOULDBLOCK错误。本人不太愿意使用此方法,一般来说,这种方法浪费CPU的利用率,这是由于CPU要不停地轮询每一个套接字,所以会浪费了CPU的时间。
另一种方法是使用select()函数立即轮询许多套接字,检查这些套接字中是否有任何一个套接字有活动。对单线程程序来说,这是一种期望的方法。本章后文中会介绍这一方法。
第三种流行的解决方法是使用多线程,让每一个阻塞调用发生在它自己的线程中,这样就不会中断程序的其他线程。第3章会详细介绍线程这一概念。






