文章目录
  1. 1. 部分区别
    1. 1.1. 头文件
    2. 1.2. 类型定义
      1. 1.2.1. socket与地址类型
      2. 1.2.2. 函数出错返回值
      3. 1.2.3. error code和宏定义
      4. 1.2.4. 其它
      5. 1.2.5. 参考
    3. 1.3. 方法定义
      1. 1.3.1. 获取错误状态码
      2. 1.3.2. 关闭套接字
      3. 1.3.3. 通用辅助方法
      4. 1.3.4. 其它
  2. 2. 用法&细节
    1. 2.1. 唤醒阻塞调用
    2. 2.2. TCP是全双工的
    3. 2.3. 单播&组播
    4. 2.4. 其它
  3. 3. 写在最后

最近因为要处理UPnP库所涉及的socket层次抽象,于是专门查看和测试了部分Windows和Unix-like系统中socket用法。总体来说,两者之间相似度很高(毕竟最初Windows上的socket方法就是从Unix系统中移植过去的),但是细节之间的差异也不小。当从Windows向Unix(Linux)系统移植或者从Unix(Linux)系统向Windows移植一个使用了socket的应用时,有些差异可以使用typedef#define等方式来消除,但是有些不同用法却需要对主要代码进行改动。

这里只会记录部分我查看的或测试过的结果。下面是我用于测试的三台主机所用的系统:

  • Microsoft Windows 10 专业版(10586.36)
  • Apple OS X Yosemite(10.10.5)
  • Microsoft Windows 10 专业版(10586.122)

其中两个Windows系统的主机连接到了同一台交换机上;而OS X系统的主机则使用无线网络连接到其中一个Windows系统创建的热点。

由于一般资料中对socket的讲解主要分为Windows和Linux,而看起来在Linux和Unix系统中关于socket的用法基本差别应该不大,比如我查看的有关Linux下socket的用法,都可以用于OS X中,而OS X是Unix系统的一个变种。所以在下面为了叙述方便,对于Unix(Linux)等统一称呼为Linux。

部分区别

头文件

在Windows下使用socket主要需要包含winsock.hwinsock2.h头文件,进行组播时可能还需要包含ws2tcpip.h头文件(这个头文件中包含了winsock2.h头文件)。

此外,Windows中链接时还需要添加ws2_32.lib的引用。

而在Linux系统中,则主要需要包含sys/socket.h头文件(通常当需要使用inet_*方法时,还需要包含arpa/inet.h头文件)。至于组播时需要的类型定义在netinet/in.h中,而它也被arpa/inet.h所包含。

类型定义

socket与地址类型

在Windows中一个socket类型被定义为SOCKET,而在Linux中则是int类型,这两者其实是类似的,因为在Windows下有如下定义(此处列出的是Win64下面定义):

typedef unsigned long long int UINT_PTR;
typedef UINT_PTR SOCKET;

对于用于表示一个socket地址的类型定义,在Windows下和Linux下基本是一样的,但是在Windows中又对部分类型进行了进一步的重新定义:

typedef in_addr IN_ADDR;
typedef sockaddr SOCKADDR;
typedef sockaddr_in SOCKADDR_IN;

至于组播时使用到的struct ip_mreq类型则没有被重定义,因此两者中是一致的。

注意:在Windows下的struct in_addr类型中包含了一个S_un的联合体,定义如下:

union
{
    struct { unsigned char s_b1, s_b2, s_b3, s_b4; } S_un_b;
    struct { unsigned short s_w1, s_w2; } S_un_w;
    unsigned long S_addr;
}

而在Linux下则是直接定义unsigned long s_addr;,并没有这个联合体。但是两者最终含义都是一样的,只是Windows下需要多一层访问路径写法,但是同时也可以直接获取到地址的对应字节值(比如用于转换成点分十进制方式)。

不过在Windows中,同时也定义了如下的宏,因此可以无缝的切换到Linux。

#define s_addr  S_un.S_addr
#define s_host  S_un.S_un_b.s_b2
#define s_net   S_un.S_un_b.s_b1
#define s_imp   S_un.S_un_w.s_w2
#define s_impno S_un.S_un_b.s_b4
#define s_lh    S_un.S_un_b.s_b3

函数出错返回值

通常在Linux下如果socket方法运行时出错了,会返回-1来表示调用出错,而在Windows中一般将socket方法返回值与SOCKET_ERROR进行比较,但是有一个不算例外的例外,那就是socket方法,当它出错时,将会返回INVALID_SOCKETSOCKET_ERRORINVALID_SOCKET与Linux之下的使用其实是一致的,因为它们的定义如下:

#define INVALID_SOCKET (SOCKET)(~0)
#define SOCKET_ERROR (-1)

可以很容易看出,INVALID_SOCKETSOCKET_ERROR的值都是-1。

error code和宏定义

关于各种出错状态码的定义,不同系统之间各有规定,包括名称、具体数值以及含义等,这个就需要参考具体的系统开发文档了。在通常情况下,Windows和Linux中关于错误状态码定义的名称(#define)和含义是一样的,但是具体的数值却可能不一样,因此,在实际使用中,我们应尽量使用定义好的宏名称来表示错误码,而不是直接写数值。

至于一些常用的宏定义,和上面的错误状态码很类似,基本都是每个系统独自定义的一套符号和值,用于socket方法的各种调用和比较。这里,在Windows和Linux中很多宏定义基本就是一样的,特别是在几个主要的socket方法中使用的宏定义,名称基本一样,比如INADDR_ANYAF_INETIP_MULTICAST_LOOP等。

而在shutdown方法中则需要注意一点,这个方法的参数是一个int类型参数,调用它的时候,所传整数值和代表含义在Windows和Linux下都是一致的,都是使用0表示关闭读通道、1表示关闭写通道、2表示全部关闭;但是如果使用宏定义的话,在Windows下分别用SD_RECEIVESD_SENDSD_BOTH,而Linux下则是SHUT_RDSHUT_WRSHUT_RDWR

其它

有一点需要注意,在OS X中,涉及到传数据并同时给出数据长度的socket方法(比如recvfrombindsetsockopt等)中,数据长度的参数类型为socklen_t,其经过展开的定义类似下面的形式:

typedef unsigned int socklen_t;

参考

  1. http://blog.csdn.net/wxqian25/article/details/8252661

方法定义

在Windows中使用socket的时候,首先需要调用WSAStartup方法初始化,之后的各种操作则类似Linux,而在结束之后还需要调用WSACleanup方法释放资源。除此之外,在Windows中则还定义了一组符合Windows消息驱动的socket方法,均以WSA开头,功能和对应的方法(比如WSASend对应send)类似。他们主要用于IOCP模型中。

获取错误状态码

在Linux下,是使用一个全局变量errno来指示错误的,即使是socket方法也不例外。但是在Windows中,则需要区分对待,对于第一个APIWSAStartup来说,如果出错,我们使用普通的GetLastError方法获取错误信息,而其它的socket方法,则都必须使用专门的方法来获取:WSAGetLastError。返回的错误ID也随不同系统而有所区别。

关闭套接字

在Windows和Linux中,我们都使用shutdown方法来关闭一个套接字的指定方向的数据流向,其参数的异同已经在上面叙说过。但是二者之间也可能有潜在的不同之处,将在下文对比说明。

至于关闭套接字的方法,在Windows中是closesocket方法,而在Linux下则是close方法,它们都需要传递一个socket描述符。

那么这两个关闭方法之间又有什么区别?在Linux下,这里有两篇文章可以参考:

这里也间接说了一点Windows下与Linux下socket编程的一点区别。因为Linux下有fork方法,因此可以很容易创建一个子进程,来处理不同的socket连接,也就是Linux下可以采用多进程实现并发;但是Windows中则没有这个fork方法,因此通常在Windows下,采用的是多线程Web服务器,因此对于Windows下和Linux下的shutdownclose/closesocket方法实现,应该还是有些区别。

在上面两篇文章中所说,如果是多进程共享一个套接字,那么close操作只会讲套接字描述符的引用计数减一,一直到其引用计数等于0的时候,才会真正关闭这个套接字(对于TCP协议将引发四次挥手过程);而shutdown却不理会套接字共享,调用之后,将会直接破坏socket的指定方向的连接(由参数决定),此时所有共享此套接字的进程对套接字进行对应操作时,都将会收到错误信息。但是与Windows下一样,shutdown并不会关闭套接字,也不会释放资源,因此最后还是需要调用close方法。

而对于Windows下来说,这两个方法并没有被设计成上面的工作方式,而是采用了一种更符合Windows编程的习惯。比如对于closesocket方法来说,调用之后,不管你是在哪个进程调用的,参数所传递的套接字都会被关闭并释放其占用的所有资源,并不存在引用计数一说;而shutdown方法也略有不同,调用该方法之后,如果参数为SD_RECEIVE,那么所有随后的recv方法调用都不被允许,这个方法对更底层的协议层没有任何影响,对于一个TCP连接的socket,如果在数据队列中还有等待接收的数据或者又接收到新的数据,那么连接将被重置,因此用户是无法接收到这些数据的,而如果是一个UDP连接,没有任何影响。无论如何,都不会产生一个ICMP错误包。类似的情况也发生在参数为SD_SENDSD_BOTH的时候,对于一个TCP连接来说,一个FIN报文将会被发送。

有关Windows下套接字的信息,可以从MSDN上找到说明。这里有一篇关闭socket连接的说明:Graceful Shutdown, Linger Options, and Socket Closure

通用辅助方法

无论实在Windows下还是Linux中,系统都提供了一些常用的辅助方法,用于处理一些常用的操作,比如在网络字节序和本机字节序之间转换、在IP地址的点分十进制字符串表示和Struct/整数表示之间转换等。

字节序的转换方面,两者都提供了类似的方法,例如htonlntohl方法转换一个unsigned long int类型,而htonsntohs方法转换一个unsigned short int类型。

而在IP地址表示的转换方面,就略有区别了,比如从点分十进制到整数的转换,Linux下提供了inet_addrinet_aton方法,而从整数转回到字符串,则提供了inet_ntoa。但是在Windows下,则没有提供inet_aton这个方法,因此对于255.255.255.255这个IP地址无法正确识别。不过从Windows Vista之后,在Windows下可以使用inet_pton来讲IP地址从点分十进制转换为整数或IPV6地址结构。

在Linux中还提供了inet_network方法,它和inet_addr很类似,都是从字符串接卸出一个整数IP地址,但是有一点区别是前者返回的是本机序,而后者则是返回网络序。至于Windows下还没有验证是否存在这个方法。

在Windows和Linux中都有inet_ntopinet_pton方法,他们不仅支持IPV4地址的转换,还支持IPV6地址的转换。

其它

这里还提到了一些其它的不同地方,比如socket控制方法,在Windows下为ioctlsocket,而在Linux下则是fcntl;而对于send方法,虽然在Windows下和Linux下两者签名一致,但是对于最后一个参数,Windows下一般传0即可,但是Linux下却最好设置为MSG_NOSIGNAL,否则如果发送出错则有可能导致程序退出;等等。如果你有兴趣,可以自行验证这些行为。

用法&细节

在Windows和Linux中socket的区别也不仅仅限于类型之间的差异,由这些类型差异当然也会导出一些细节用法的不同,在此就不在赘述了,毕竟上文也大概提到了。下面主要陈述一些其它的细节用法和说明,而未必是平台之间的差异。

唤醒阻塞调用

这个也许是我曾搜索时间最长的一个问题了,当然,问题也并不是如何简单的关闭一个套接字,更详细的说,是如何(安全的)唤醒一个阻塞的socket方法调用,主要是读写方法。

socket可以工作在两种模式下:阻塞模式(默认)和非阻塞模式。这两种方式各有优劣,这里就不做说明了,通常的任务,使用默认的阻塞的阻塞模式就可以很完美的工作了,而且控制也简单,无需像非阻塞模式下那样添加额外的处理判断以及线程调度等。但是现在问题就来了,如果一个程序中使用了阻塞的socket调用,但我们希望可以随时退出程序,该怎么办?很不幸,在网络上搜索的大部分答案都是:使用非阻塞调用方式……就不讨论对于一般小型的任务,使用非阻塞模式的不便利以及浪费资源了,这里都已经提出期望了,如何退出一个阻塞的socket调用,为什么还总是要回答非阻塞。而也有一两个答案提出使用shutdown方法,只有几乎很少的回答提到了使用closeclosesocket)方法。

有关socket的一些用法在后文提到,这里不重复了。

对于一个阻塞的socket调用,其实我们这里要做的就是想办法让其从调用中返回,从而让线程继续执行,然后正常终结。(这里你也可以试试粗暴的关闭阻塞的socket调用所在线程,但是不提这样的方式是否优美,这种粗暴的做法,也不保证可以回收所有资源。)而如何让socket调用返回?正常情况是方法完成了任务,比如收到了数据,而大部分情况下,我们也是阻塞在这个接收数据的方法上,因此也有人提出了这样的解决方案:自己定义一种协议,然后在要关闭套接字的时候,新开一个socket,像原来的在等候的套接字发送一个指定格式的数据,从而达到唤醒的目的。不过这种方式对于UDP连接还可以,对于一个TCP会话就不好做到了,毕竟TCP连接是完备的五元组标示的一个连接,非目的地发出的数据,操作系统也不会把数据投递到另一端。

而再回过头来看socket提供的主要方法,能够让一个阻塞的调用返回到其调用者的,也许就只有shutdownclose/colsesocket方法了。按照网上的说法,可以调用shutdown并传递合适的参数就可以唤醒阻塞的调用,比如如果是一个read方法阻塞,那么可以通过传递SD_RECEIVESHUT_RD来“破坏”socket读通道,从而导致读方法返回,类似的也可用于写操作上,至于读写通道同时关闭的SD_BOTHSHUT_RDWR当然同样合适。这个说法看起来很有理,然后,我在测试过程中发现,如果仅仅调用这个方法,无论是在Windows下还是OS X中,都无法让被阻塞的方法返回。没办法,只有再试试close方法了,而这时,根据打印的log来看,就可以直接看到阻塞调用返回了,然后子线程正常终止,最后就是主线程等待子线程都结束之后才返回,根据log时间来看,被阻塞的方法都是立即返回的,自然携带了一个错误信息:SOCKET_ERROR

至于这样做有没有什么副作用,目前还没有发现,虽然在MSDN上有过一句话,就是提示closesocket方法不要与其它Winsock方法同时调用,但是同时也可以在MSDN上看到,closesocket也保证回收socket所占用的资源,并且还说到,当调用closesocket时,任何未运行的重叠发送和接收方法都会取消,相应事件、完成端口动作等都会被执行,同时,该方法将会在其它的I/O操作上初始化一个取消标记。有关具体的行为和配置,可以从上面的“关闭套接字”一节中附加的MSDN链接中找到详细说明。而对于Linux下来说,系统应该也会进行类似的保证。

因此,目前我就是通过调用close/closesocket方法来实现唤醒其它线程中的阻塞调用的,当然具体用法还需要在根据系统给出的API文档决定细节调用,比如在MSDN中提到,close一个socket时,将会自动发送FIN报文,也就是对于TCP连接来说,就会步入四次挥手过程,而这个时候,就可以配置closesocket方法是立即返回还是等待这个关闭过程完成,默认配置是立即返回,然后剩下的由操作系统完成。

TCP是全双工的

正因为TCP是全双工的,因此我们可以在一个TCP连接上同时执行读写操作,也就是同时进行发送和接收行为,而实际上,对于一个UDP连接的socket对象来说,我们也可以在上面同时进行收发操作,这点在有些时候就可以给我们的程序设计提供很大的便利,比如说我们可以在一个socket因为接收数据被阻塞的时候,从其它线程继续使用这个socket连接发送数据,这在有些读写操作不串行的时候很有用,比如在一个组播中,就需要一边监听组播消息,而又可能同时需要使用当前地址向组播会议中发送消息。

至于为什么我们可以同时在一个socket对象上进行读写操作,这是因为socket对象的读写缓冲区是分开的,因此两个操作完全互不干扰,也就不存在多线程访问时候的竞争问题了。

当我们记住这点,并且明白在两个线程中同时操作一个socket对象(当然,同一时刻一个只负责读,另一个只负责写)是不会发生意外的,也不需要额外的线程同步工作,在很多时候,可以方便我们的程序设计,比如下文中的组播示例。

单播&组播

简单来说,单播就是一对一传输信息,而组播就是一对(特定的)多传输信息。而多扯一句,广播则是完全的一对多,也就是对网络上所有设备。

也许你认为需要两个socket对象才能同时完成对单播信息和组播信息的接收与发送,一个socket用来作为单播接收服务器,另一个作为组播监听服务器;但实际上,只需要一个就够了。

当然,由于涉及到了组播,因此我们这里提到的单播也是使用UDP协议。

我们可以在一个socket对象上完成单播和组播。首先说单播,其配置就和普通的用法一模一样,因为我们在这里还需要同时在这个socket对象上完成监听任务,也就是允许它像一个服务器那样工作,因此我们必须要在这个socket对象上调用bind操作,以将其绑定到一个地址上,通常我们使用下面的方式,使得socket对象可以接受来自任意地址的消息:

sockaddr_in local;
local.sin_family = AF_INET;
local.sin_addr.s_addr = htonl(INADDR_ANY);
local.sin_port = htons(/* 端口号 */);
if (-1 == bind(socket, (sockaddr *)&local, sizeof(local)))
{
    // 处理错误
}

这里有一个小问题需要注意,如果你在上述代码所位于的编译单元中添加了对命名空间std的引用并且使用-std=c++11编译,那么在编译时候可能会出点问题,编译器可能会报错,提示无法将int类型与__Bind(xxx)类型进行比较 这样的类似信息,而原因很简单,因为在C++11之后,命名空间std中也有一个bind方法,而有些编译器可能将这里解析成对std::bind方法的调用了,因此就出现了错误,解决方法也很简单,将你所有的bind调用都改成::bind,也就是提示编译器,我们使用的是全局命名空间的bind方法(这是一个C方法,而来自C语言的方法都位于当前编译单元的全局命名空间中)。并不是所有编译器都会报这样的错误,比如同样的方式,我在Windows中使用MinGW-gcc就没有问题,而在OS X下面使用XCode所带的编译器(名为gcc,实际应该是clang了吧)就会出现这样的错误。

当我们绑定地址之后,就可以按照传统方式,在一个新的线程开始监听socket信息了,这一切,都和你之前所做的一样,而在这之后,你就创建了一个支持单播的socket连接了,可以在其上发送消息(如上一节所述),也可以接收消息。

那么接下来,就是再为这个socket对象启用组播属性了,启用方式很简单,在上面的socket对象上调用setsockopt配置相关属性就可以了,单纯的向系统申明支持接收组播消息可以按照如下方式:

ip_mreq imMreq;
ipMreq.imr_interface.s_addr = htonl(INADDR_ANY);
ipMreq.imr_multiaddr.s_addr = htonl(/* 组播地址 */);
if (-1 == setsockopt(socket, IPPROTO_IP, IP_ADD_MEMBERSHIP, (char *)&ipMreq, sizeof(ipMreq)))
{
    // 处理错误
}

我们设置imr_interface.s_addr来向系统说明我们希望使用哪个网卡来发送消息,而imr_multiaddr.s_addr则是希望加入的组播会议室(只有加入之后才能接收该组播上的消息)。这样我们就可以接收组播消息了,至于接收方法?当然是和单播一样了,对于UDP而言就是使用更常用的recvfrom了,因此你只需要在原来的代码中添加类似上面的代码就可以了,其它的地方不需要改动。

在加入组播之后,我们也还可以使用setsockopt来设定一些其它的组播设置,比如是否接收自己发送出去的消息(IP_MULTICAST_LOOP)、组播数据报文的TTL值(IP_MULTICAST_TTL,默认为1,也就是当到达第一个路由器之后就会被丢弃,因此限制了组播报文只能在一个局域网内部传送,如果你的数据报必须要跨过路由器继续传送的话,你必须修改这个值为一个合适的值)……等等。

这里也有一个奇怪的问题,在设置时候IP_MULTICAST_LOOP的时候,我使用了一个unsigned int类型变量作为setsockopt方法的第四个参数,但是结果在Windows下运行正常,而到了OS X中却没有作用,目前尚未深究原因,想来应该不会是大小端的原因,因为这个变量的值为0。

而最后就是向组播会议室中发送消息了,这个依然和单播时一样,使用sendto方法,不同的是,此刻你需要将sendto的接收地址设置为一个组播地址,而你的操作系统自然会识别出来,并自动将这个消息作为组播消息发送出去。

其它

一个TCP连接可以使用一个四元组来唯一标示:<发送地址, 发送端口号, 接收地址, 接收端口号>,而对于一个socket连接来说,应该是使用一个五元组来唯一标示一个连接:<发送地址, 发送端口号, 接收地址, 接收端口号, 协议>,而在操作系统内核中,要求每个socket连接都是唯一的,在bind方法中,将会设置一个socket对象的发送地址以及发送端口号(当然,这个方法也同样会导致操作系统设置这个socket对象可接收数据的范围,也就是只有数据包的目的地址为你绑定的地址时,才会将该数据包发送到这个socket),在connect方法中,将会设置接收地址接收端口号,这样,一个socket就可以被唯一确定了。

注意一个UDP协议的socket连接,也可以使用connect方法,与TCP中不同的是,该方法在UDP协议下仅仅设置socket的目的地址,而不会进行三次握手操作。当你在一个UDP连接中调用了connect方法后,你也可以像在TCP连接中那样使用send方法和recv方法,这个时候目的地址就是你connect的地址,这在某些情况下可以优化性能。

我们可以使用SO_REUSEADDR选项或者SO_REUSEPORT选项来指定某一个socket上的地址可以被“重用”,当然这里并不是说操作系统允许同时存在两个相同的socket连接,这个重用一般用于监听服务器上,可以使得一个位于TIME_WAITE状态的socket绑定地址立即可用于新的绑定。此外,SO_REUSEADDR还会影响到系统对通配地址的解析规则,有关这两个选项的更多信息以及操作系统之间的实施区别,可以从网上搜索到更详细的资料,比如这篇文章:http://blog.chinaunix.net/uid-28587158-id-4006500.html

当你对一个UDP连接使用SO_REUSEADDR或者SO_REUSEPORT时,不同于TCP的情形,这时候操作系统将会允许多个socket“完全的”重用一个地址(包括端口号),当然为了防止“端口窃听”,操作系统也会对这种情况有所限制或者应对措施。这个时候,你就可以在一个进程或多个进程中将socket绑定到同一个地址了,当消息来临的时候,这些socket都会收到。这种情况我已经在Windows下验证过了,一个App的多实例都可以接收到发来的消息。

写在最后

在这里所记录的远远没有穷举完有关socket的知识,以及它们在不同平台下的差异,如果你确实需要编写跨平台的socket相关程序,那么你需要亲自去对比平台差异,并亲自去试验了解不同的平台提供的API,甚至也不妨对有些API自己去造一把轮子。

而更多时候,我们还是会针对某一个平台或一系列相关的平台进行开发,这样的话,也许相关平台的操作系统会在基本的BSD形式的API之外添加更多适用于平台特性的API,这些API也许无法跨平台,但是却是可以针对特定平台优化使用以及性能,比如Windows下的WSA*方法,可以用于IOCP模型,而在OS X中,也在上述通用API之外提供了更多的易用的socket方法。使用这些特定于平台的方法,也许可以给开发带来更多的方便。

文章目录
  1. 1. 部分区别
    1. 1.1. 头文件
    2. 1.2. 类型定义
      1. 1.2.1. socket与地址类型
      2. 1.2.2. 函数出错返回值
      3. 1.2.3. error code和宏定义
      4. 1.2.4. 其它
      5. 1.2.5. 参考
    3. 1.3. 方法定义
      1. 1.3.1. 获取错误状态码
      2. 1.3.2. 关闭套接字
      3. 1.3.3. 通用辅助方法
      4. 1.3.4. 其它
  2. 2. 用法&细节
    1. 2.1. 唤醒阻塞调用
    2. 2.2. TCP是全双工的
    3. 2.3. 单播&组播
    4. 2.4. 其它
  3. 3. 写在最后