前言
最近工作和socket打了不少交道,对于socket也是一知半解,导致遇到不少问题,于是抽时间好好学习了一下socket相关的知识。本文主要对socket关键字进行详细分析,深入理解了不同关键字在不同情况下的行为表现,一些相关的工作原理,最后以一个简易的一对多客户端和服务端通信Demo来实地分析和学习socket工作原理。
什么是socket
Unix高级编程书中这么描述socket:不同计算机进程间可以通过网络通信。而socket(套接字)即通信的一个载体,用专业数据讲就是套接字是通信端点的抽象。socket在系统标准库定义一套接口,需要使用必须include<sys/socket.h>。
其实在开始时我一直不理解socket是什么,当接受了Unix系统一切皆文件这个概念之后,大概明白了文件描述符的意思,socket即表示用于I/O的文件描述符。也就是返回这个socket数字是一个文件的描述,通过这个socket可以对文件进行读写。说白了socket就是对于网络端口的封装,发送数据即用户进程把数据写入到系统进程缓冲区,然后通过网络链路发送到对方的系统进程的缓存区。而取数据就是用户数据去系统进程的缓冲区去取。因此数据传输受系统进程缓冲区的限制,比如常见的粘包问题,和发送读取数据不全很可能是缓冲区受限。socket这个函数类似于open文件,打开的是一个用于通信的文件,它需要传入的不是文件路径,而是指定通信协议和数据传输方式。
1 | include<sys/socket.h> |
- domain确定通信的特性,包括格式地址(各个域通常以AF_开头,意指地址族)
| 域 | 描述 |
|---|---|
| AF_INET | IPV4英特网 |
| AF_INET6 | IPV6英特网 |
| AF_UNIX | UNIX域 |
| AF_UNSPEC | 未指定 |
- type确定套接字的类型,进一步确定通信特征。
| 类型 | 描述 |
|---|---|
| SOCK_DGRAM | 长度固定的,无连接的不可靠报文传递 |
| SOCK_RAW | IP协议的数据报接口(POSIX.1中为可选)(直接访问IP,用于自定义通信协议) |
| SOCK_SEQPACKET | 长度固定、有序、可靠的面向连接报文传递 |
| SOCK_STREAM | 有序、可开、双向的面向连接字节流传递 |
protcol通常是0,表示按给定的域和套接字类型选择默认协议。如果domain是AF_INET并且type是SOCK_STREAM则协议为TCP。如果domain是AF_INET并且type是SOCK_DGRAM则协议为UDP。
当然也可以进行指定。
字节序
字节序是一个处理器的架构特性,表示了字节排布的顺序。字节序有大端和小端之分,大端表示把数据的高字节保存在内存的低地址,小端表示把数据的高字节保存在内存的高地址。如果不确定数据的字节序读取的数据则完全是错的,因此数据在传输时要么确定自己的字节序不会变,例如网络中以大端传输,要么就得在数据中带上自己的字节序。
这里有个有意思的事,很多经典书籍比如Unix环境高级编程在介绍字节序是会说Mac是大端的,然而我实测却是小端,搞的我怀疑人生。。后来经过查询资料,原来早年的苹果电脑是PowerPC架构是大端的,而如今的x86架构系统是小端的。所以万事还是得自己实测一下比较好。。。
我们知道内存中地址顺序如下:
低地址——->高地址
对于数据a = 0x04030201,如果处理器支持大端字节序, 则内存为04 03 02 01 ; 如果处理器支持小端字节序, 则内存为01 02 03 04。以mac为例如下:
1 | (lldb) x &a |
以下是两种判断字节序的方法:
1.将数字转成char *地址,取首字节的值进行判断
2.通过联合体进行判断
1 | union test { |
由于网络通信中都是以大端形式,因此socket也提供了一些字节序转换函数:
1 |
|
关于字节序更多的内容,可以参考阮一峰老师的博客。
地址格式分析
由于在C中没有类与对象,所以往往以结构体表示一类信息,在socket编程中常用一下几个结构体来表示地址信息:
sockaddr_in
sockaddr_in是一个用于表示socket地址的结构体,它可以传递IP和端口号,以及制定协议族。 必须在初始化时全部通过bzero把填充字段置为0, 地址必须有效为当前机器的地址。端口号必须不小于1024。地址必须与创建套接字时所支持的格式匹配。
1 | struct sockaddr_in { |
addrinfo
getaddrinfo是IPV6上出的用户获取IP的函数,通过传入一个指定的域名或者IP获取到指定的地址信息。因此我们可以利用它对地址进行检查,也可以通过它获取sock_addr数据。
1 | //use addrinfo |
我们详细看一下addrinfo这个结构体,可以看到除了通用的一些设置,内部还有个指针指向了sockaddr,这个指针指向的便是地址信息。而由于一个域名可能对对应多个IP,这个结构体还是链式的,用ai_next指向下一个IP的信息。
1 | struct addrinfo { |
ifaddrs
ifaddrs用于获取当前的接口信息,它的结构和addrinfo很相似,也是链式的,也包含有sock_addr指针来表示地址信息。 getifaddrs函数用于获取ifaddrs数组,表示当前设备的所有硬件接口信息(当然也可以拿到IP信息拉啦):
1 | struct ifaddrs { |
以一个例子来看下如何通过getifaddrs获取本机所有接口信息,假如我们对接口有要求,比如我们限制只能通过WIFI连接,则我们就可以过滤出接口名包含en的接口。然后获取其地址,这样就可以指定通过WIFI网卡的接口来进行通信了:
1 | struct ifaddrs *addrs = 0, *firstAddr = 0; |
这些都是我mac电脑上的接口,接口顾名思义就是和外界通信的端口。由于我再查找WIFI之后中断了,更多的介绍可以参考stackoverflow上这个回答。
关键字
获取错误
万事开头难,无论学啥第一应该会的是看懂错误,否则出了问题会一脸懵逼。socket提供了一个errno宏命令用于传递错误码,实际它是一个指向int *指针函数的int型变量。当出现错误时,socket标注库会把错误传递进去,因此它只会记录最后一次错误。开发者可以通过errno获取到当前的错误码。同样socket标准库还提供了一些方法来打印错误:
1 | //将errno转换成字符串 |
绑定地址
对于服务器需要绑定一个特定的地址,以使客户端可以通过该地址与之通信。地址必须包含指定的目标地址,目标端口号,如果成功会返回0。否则可以打印errno获取失败原因。
1 | //将套接字与地址绑定,成功返回0,否则-1. 端口必须不小于1024 |
监听连接请求
服务器调用listen可以宣告何时可以接受连接请。count指该进程所要入队的连接请求数量。
一旦队列满了则系统会拒绝剩下的请求。一旦调用了listen,服武器就可以开始接受连接请求了。如果成功会返回0。否则可以打印errno获取失败原因。
1 | //sock_fd表示server |
接受连接
服务器调用accept来获得连接请求并建立连接。该描述符连接到调用connect的客户端。如果不关心客户端表示,可以将addr和len设置为NULL。对于阻塞式TCP如果没有连接到来,accept会一直阻塞,只到一个连接事件发生。如果sock_fd处于非阻塞模式,accept会立刻返回。通常由于三次握手的原因可能会返回-1,并将errno设置为EAGAIN或EWOULDBLOCK。
1 | //接受一个到server_socket代表的socket的一个连接 |
请求连接
connect中的地址是想与之通信的服务器地址,以及地址长度。 如果成功会返回0,否则返回-1,此时可以通过errno获取失败的原因。connect函数通常会阻塞直到返回,这个时间大约为75s到几分钟,因此为了提高性能,通常设置为非阻塞模式如果是非阻塞,会立即返回结果,有可能因为三次握手的原因导致产生EINPROGRESS错误。一般此时利用select等待之后检测套接字是否可写,如果可写即认为连接成功。
1 | connect(int, const struct sockaddr *, socklen_t) |
数据发送
send用于发送数据到系统进程缓冲区,然后写入链路。当启用了Nagle算法,会在缓冲区进行小包的重组。由于缓冲区并不一定大小完全可用,send的返回值表示真正写入的字节。
1 | //发送数据,成功返回发送的字节数,否者返回-1. |
数据接收
recv是用户进程去系统进程的缓存区去取数据,可以通过buffer_len参数指定接收长度,实际收到的长度以系统缓冲区的缓存大小为准,并且不大于指定的接收长度。
1 | //若无可用消息或对方已经按序结束则返回0,若出错返回-1(对非阻塞式当系统进程的没缓存时也会返回-1) |
关闭连接
关闭连接可以通过close和shutdown:
1 | int shutdown(int socketfd, int how); |
shutdown函数可以中断套接字套接字通信是双向的,因此调用shutdown可以停止输入/输出。how描述关闭哪一端,SHUT_RD关闭读取,SHUT_WR关闭写入,SHUT_RDWR同事关闭读取写入端。可以只关闭一个方向的传输。
close关闭对文件或者套接字的访问,并且释放该描述符以便重新使用。close会将套接字的引用计数减一,只有所有引用全部被关闭后才会释放Socket。通常在close后socket并不会立刻关闭,此时会处于TIME_WAIT阶段,以防接收不到最后的包。
select
GUN官方文档如此描述select函数:
1 | You cannot normally use read for this purpose, because this blocks the program until input is available on one particular file descriptor; input on other channels won’t wake it up. You could set nonblocking mode and poll each file descriptor in turn, but this is very inefficient. |
前面的一些关键字都是阻塞式的,会阻塞当前的线程,直到事件发生,同一时刻只能给一个客户提供服务。因此要么阻塞当前的线程,要么就为每一个客户端启用一个线程进行数据的传输,可以参考第一个例子。
另一种方式就是采用非阻塞属性,关于属性看一看下一节。
而select是允许程序监听多个文件描述符,直到一个或多个文件描述符可以用于I/O操作,则会通知到服务端进程。这样可以使一个服务端进程对应多个客户端进程。select会返回发生修改的文件描述符个数。如果为0表示没有文件描述符发生改变,如果小于0则可能是哪里出错了。
1 | //max_fdp,指集合中所有文件描述符的范围,即所有文件描述符最大值加1。 |
首先需要理解select模型,select采用位域来表示文件描述符,可以看到实际是一个以32个bit对齐的数组。每一个fd_bit对应一个文件描述符。我们知道在Unix系统中,一切都是文件,所以本质上还是对文件的监测。文件描述符个数是固定定义的,并且苹果官方注释不要重定义文件描述符大小。
1 |
|
系统还定义了一些宏命令用于操作fd_set:
1 | void FD_ZERO(fd_set *fdset); /* 初始化文件描述符*/ |
文件描述符最大总字节数(bit数,因为__DARWIN_NFDBITS乘了8)为1024。也就是说最多数组中最多只有32个数,但是并不表示只有32个文件描述符。
可以看到FD_ISSET的实现为:
1 | /* This inline avoids argument side-effect issues with FD_ISSET() */ |
也就是每个socket只占用一个bit来进行它的文件描述符,因为只需要表示可读/不可读。可写/不可写。我们做个测试:
1 | fd_set read_sets; |
初始化一个socket只会,传入一个fd_set。此时打印fd_set可以看到:
1 | (lldb) po server_socket |
第五个bit被置为了1。也就是说此时把socket(文件描述符)注册到fd_set中,这里其实也不难理解为啥要分32个一组,主要也是为了方便查找和管理。
而FD_ISSET表示,fdset指向的文件描述符集是否包含了指定的socket(文件描述符)。其实就是获取哪一个bit是否为真。因为在调用select函数之后,发生变化的文件描述符将会被保留,其余字段都将变为0。由于每次select会重置监控句柄,所以每次select都需要先传入一个新的fd_sets。
由于文件描述符上限为1024。也就是说socket(文件描述符)最多只能有1024个存在,即最多只能创建1024个TCP连接。
当然也可以通过一些手段去除这个限制,感兴趣可以参考这篇文章。
这里有一些重要的点是:1,select的超时时间并不是从被调用开始的,调用所需的时间也不算在倒计时里,因此如果系统繁忙,则超时会在获得处理机时才开始,非常不准确;2.当select执行完成时会修改timeout参数,由于传入的是指针,因此如果需要重复使用必须重新设置该参数。
关于fd_set更多详细的信息可以参考Liunx官网。
TCP状态转换模型
这一段摘自维基百科对于TCP的介绍,偷个懒,直接搬运过来了:
断开连接的4次握手,主要是为了保证两端都能close。需要注意的是被动close的一方进入CLOSE_WAIT状态等待LAST_ACK,当收到LAST_ACK就会完全close掉。而主动close一方进入TIME_WAIT状态以确保最后的数据能收发干净,并且不影响新的连接。由于此时被动close方已经完全关闭了,因此主动连接方只能等待一段时间自己关闭,而这个时间一般为2*MSL,MSL指的是TCP数据段在网络系统中存活最大时间,一般为2分钟。

由于TIME_WAIT的存在,导致在close之后系统资源并不能立即释放,如果存在大量连接,可能导致系统资源持续的高占用。并且由于timeout的存在导致服务端不能实时重启。 同样的服务端主动close也会导致大量客户端进入CLOSE_WAIT状态。
可以看到看到TIME_WAIT其实是一种正常的TCP的优化手段,并且时间基本是固定的不会再受其它的原因影响,只是大量的TIME_WAIT才会导致问题,因此需要时只需要避免TIME_WAIT的存在即可。
但是如果长时间处于CLOSE_WAIT则说明B端其实是有问题,要么是没有发出FIN,要么就是没有收到A端的ACK,说明程序有问题或者链路有问题,并且会被一直卡着,而不像TIME_WAIT到时间自己关掉。所以更需要重视的CLOSE_WAIT问题。
Sockect属性设置
setsockopt和getsockopt函数用于设置获取socket的属性,
1 |
|
是一个系统层的socket参数。所有的参数项可以参见gnu的官网介绍.
我们只关注几个常用的参数:
SO_LINGER
1 | struct linger l; |
我们知道socket在主动断开连接后会保留将近2分钟的TIME-WAIT,所以如果存在大量的短连接,会导致大量的TIME-WAIT,导致占用系统资源。通过设置SO_LINGER可以限制close之后释放资源的时间,会把sendbuffer中未发完的数据丢弃,并且发送的是RST信息,而不是正常的四次握手断开TCP连接,因此不会进入TIMEWAIT状态。这样做最大的缺点就是可能导致最后的包没有收到,所以如果需要传输的资源是大量小文件,而且不应该丢失就不要设置这个属性。
SO_REUSEADDR
在实验中,发现当我重启服务端,重复bind一个端口时会报错。
1 | Server Bind port : 2234 Failed!, errno: Address already in use |
因为我们使用的TCP可靠连接,因此只允许有一个socket绑定特定的IP和端口号。在重启服务进行端口bind时因为TIMEWAIT当前端口仍然被上一个socket绑定所以报错。而设置SO_REUSEADDR参数既可以重用当前的地址,但是属性必须在调用bind函数之前设置:
1 | int value = 1; |
不过SO_REUSEADDR也有风险,调用之后,前一个socket未接收到数据将会被丢弃,但是如果等待TIMEWAIT结束又可能导致新连接不能成功,这段时间内的数据丢失,所以需要根据实际情况来做选择是否使用该属性。
TCP_NODELAY
在TCP中,有一个叫Nagle的算法。大概就是就是一个delay的算法,会把一些小块的包合并以提高传输效率。我们知道每一个包都有包头,所以合并小包其实可以避免这一部分的浪费,
一下两张图都摘自:https://en.wikipedia.org/wiki/Transmission_Control_Protocol#Connection_termination。 图侵删。


在IPV4,每一个包头要站32个字节,IPV6中每一个包头要占将近60个字节。 对于早期互联网网速并不发达时,这个带宽消耗还是不小的,因此诞生了这个算法。
而如今网速早已不再存在这种问题,Nagle算法反而导致数据延迟,因此我们需要设置属性避免这个问题:
1 | int value = 1; |
SO_NOSIGPIPE
我们知道在Unix系统中利用信号机制进行进程间的通信,信号触发进程的中断,并且触发信号处理函数,比如如下SIGPIPE信号,表示对一个已经关闭的socket写入数据,会导致App进程crash, 我们以Demo1为例子,只启动client,然后直接朝fd里写数据,可以看到log如下:
1 | [Client1] Connect failed, errno: Connection refused |
13正是SIGPIPE的实际值。在实际开发中,难保不会发生在写数据时候socket发生了close而导致程序崩溃一般在App端可以如下处理:
1 | #import <signal.h> |
点开signal的声明可以看到函数的原型,这里非常有意思,值得思考一下:
1 | /* |
首先__BEGIN_DECLS,__END_DECLS宏命令是gun的buildin宏,为了避免C++对符号进行重组导致Undefined Symbol错误。在GUN官方文档中可以看到详细介绍,我们在查看一些系统级头文件时经常会看到这个宏。
然后signa函数的声明非常有意思,我也是看了一会才看明白这里指针的艺术。
这里先分析下:
首先先看最里层有一个函数,它是一个函数的指针,参数为int型,无返回值。假如我们以func称呼它:
1 | typedef void (*func)(int) ; |
然后像反向剥洋葱一般展开,可以看到再外一层是一个命名为signal的函数指针,它有两个参数,一个为int型,另一个是func1函数指针,由于它只是函数原型中的一个声明,因此并没有命名:
1 | signal(int, func); |
把signal作为一个指针再向外展开,可以看到其实是一个signal指针函数,它的参数为int型,返回值为空。
1 | void (*signal)(int); |
因此可以改写成:
1 | func signal(int, func); |
那么我们可以猜测一下系统内核的实现大概是:
1 | static func handlers[100]; |
则在App端我们捕获SIGPIPE信号:
1 | //在.m里我们模拟cacth signal |
则我们模拟一下出现异常信号,先触发添加捕获的SIGPIPE,在随意触发一个SIGKILL,则在console可以看到log如下:
1 | catch signal: 13 |
9恰好就是未进行捕获的SIGKILL。13则已经被捕获到了。
当然另一种方式,在设置属性避免socket发出SIGPIPE信号,不过这种属性只会对send函数生效。
1 | int value = 1; |
O_NONBLOCK
O_NONBLOCK表示非阻塞,可以通过设置它实现非阻塞socket。fcntl函数用于操作socket,F_GETFL参数表示获取参数,F_SETFL参数用户设置参数,在系统源码中为了提高效率,很多都采用了位域运算来表示信息。
1 | //先利用fcntl函数获取socket参数 |
实现简单的服务端与客户端
Demo地址
首先以实现一个简单的阻塞式的服务端与客户端来理解socket的工作原理:
服务端
对于服务端则工作流程为:

由于我们使用的是accept函数会阻塞当前线程,它会阻塞线程直到连接事件发生。因此不能在主线程中处理accept,同样为了不阻塞已连接线程的数据传输,应该单独为其启用一个线程进行数据的传输。所以当客户端较多时,多线程带来的性能损耗非常大。
为了和连接的客户端通信,我们需要把连接进来的客户端套接字保存下来。然后在收发数据时根据指定的套接字就可以和不同的客户端通信。当客户端断开连接时,把客户端套接字移除掉,这样一个简易的聊天系统就有了雏形:
初始化
代码
1 | - (BOOL)initServer { |
需要注意由于默认构建的是阻塞式socket,因此会卡在accept函数,直到接收到第一个连接位置,因此应该把accept函数放在另一个线程,避免阻塞主线程。
建立连接
代码
1 | - (void)run { |
首先梳理一下这里的逻辑,有一个特点的线程监听连接事件。在while循环里,如果没有连接事件发生,就会阻塞在accept这里,如果有一个客户端发起连接,则accpt返回客户端socket。并且继续执行,开启一个新的线程用于该客户端数据的读取。而循环再一次执行,再一次阻塞在accept处,等待下一个连接事件发生。
需要注意的是accept函数会阻塞,直到一个连接到来并返回客户端套接字,此时我们可以会这个客户端套接字开一个线程进行与之相关的数据传输,注意这个客户端套接字是通信的唯一标识符。为了实现与多个客户端通信,我们应该在客户端连接时保存下客户端套接字。
数据接收
代码
1 | // 读客户端数据 |
先分析下逻辑,由于recv函数会阻塞直到接收到数据才返回数据的长度,因此需要专门有一个线程处理当前的客户端发送的数据。使用while循环保持线程常驻。 为了减少buffer的开销,我们可以用一个buffer即可。recv函数会memcpy数据进buffer, 因此只需要取接收的长度的部分即是客户端发来数据。在通知给delegate时我们带上客户端socket,这样就可以知道到底是哪个客户端发来的数据。
需要注意recv函数返回的值是实际接收的字节长度,send函数返回的是实际发送的字节长度。如果出错误则返回为-1。如果客户端断开,recv返回为0。如果出现错误返回为-1,可以通过strerror(errno)来打印错误。send和recv函数都可以指定接收和发送数据到对应的套接字。
数据发送
代码
1 | - (void)sendMsg:(NSString *)msg toSocket:(int)client_fd{ |
send时指定一个客户端socket进行发送即可发送到指定的客户端。
客户端
客户端与服务端很相似,也是需要需要初始化一个套接字,然后连接的指定的地址,然后进行数据传输。

初始化
代码
1 | - (void)start { |
对于客户,相对比较简单,只需要向客户端套接字发送指定,需要注意的connect是在TCP连接中需要完成三次握手,时间不定,默认connect具有75秒以上的超时时间,因此可以采用非阻塞的socket来防止。如果serverSocket不可连接则会直接返回-1, 并且errno会包含错误原因。
使用非阻塞还有一个好处是,可以在connect后进行短暂的等待,避免线程阻塞后长时间占用系统资源,通常规定一个等待时间后fd_set仍然不可用,就停止连接释放线程资源。
使用select提高性能
为了提高效率我们,对Demo1中的client和server进行优化,采用非阻塞的方式实现。
我们利用select来检测连接的socket是否有错误:
代码
1 | - (void)run { |
而在读数据时,利用select只需要一个线程即可,依次检测哪个socket可读,则取出缓冲区的数据。
代码
1 | - (void)startRecv { |
而在发送数据时,同样先利用select判断socket是否可写,并且要判断发送的数据是否完全发送:
代码
1 | - (void)sendMsg:(NSString *)msg toSocket:(int)client_fd { |
所以最终呈现出来的效果如下,一个服务端对接3个客户端:

粘包问题
即使我们再前面设置了非阻塞,切取消了Nagle算法。粘包问题依然无法解决,因为TCP作为面向连接的字节流数据传输,可能因为网络问题在发送端的系统进程缓冲区粘包,也可能因为系统繁忙读取没跟上,在接收端的系统进程的缓冲区出现粘包。 一个最简单的复现方式就是在接收端打一个断点,挂起recv线程,当再次resume时会发现收到的数据已经是多包粘连在一起。因此最好的解决方法还是指定协议是约定好数据包的分隔符,在接收端重新组包。
参考资料
1.UNIX网络编程
2.https://linux.die.net/man/3/fd_set
4.https://stackoverflow.com/questions/3757289/tcp-option-so-linger-zero-when-its-required
5.https://www.gnu.org/software/libc/manual/html_node/Waiting-for-I_002fO.html
6.https://zhuanlan.zhihu.com/p/65810324
7.https://en.wikipedia.org/wiki/Transmission_Control_Protocol#Connection_termination
8.https://stackoverflow.com/questions/3757289/tcp-option-so-linger-zero-when-its-required
9.https://en.wikipedia.org/wiki/Maximum_segment_lifetime
10.http://cmd.inp.nsk.su/old/cmd2/manuals/gnudocs/gnudocs/libtool/libtool_36.html
11.https://superuser.com/questions/267660/can-someone-please-explain-ifconfig-output-in-mac-os-x