套接字 socket是操作系统内核的一个数据结构,它是网络中节点进行相互通信的门户。网络编程实际上也可以称作套接字编程。
套接字有3种类型:
SOCK_STREAM
表示SOCK_DGRAM
表示SOCK_RAM
表示套接字地址结构由网络地址和端口号组成。
(1)TCP
TCP是一个面向连接的传输层协议,在数据发送之前(即进程通信之前),必须先建立连接。通信完毕后,必须关闭连接。基于TCP传输协议的服务器与客户机间的通信工作流程如下图:
大致流程如下:
socket()
函数来建立一个套接字,用这个套接字完成通信的监听及数据的收发。bind()
函数来绑定一个端口号和IP地址,使套接字与指定的端口号和IP地址相关联。listen()
函数,使服务器的这个端口和IP处于监听状态,等待网络中某一客户机的连接请求。socket()
函数建立一个套接字,设定远程IP和端口。connect()
函数连接远程计算机指定的端口。accept()
函数来接受远程计算机的连接请求,建立起与客户机之间的通信连接。write()
函数(或close()
函数)向socket中写入数据,也可以用read()
函数(或recv()
函数)读取服务器发来的数据。read()
函数(或recv()
函数)读取客户机发来的数据,也可以用write()
函数(或send()
函数)来发送数据。close()
函数关闭socket连接。(2)UDP
不同于TCP协议,UDP是一个无连接的、不可靠服务的传输层协议,它不对数据进行确认、出错重传和排序等可靠性处理,但它却是具有代码小、实现简单那、速度快和系统开销小等优点。对于某些应用,使用UDP将带来更高的效率,如域名服务系统DNS、网络文件系统NFS等。
基于UDP传输协议的服务器与客户机间的通信工作流程如下图:
对比TCP套接字通信流程,区别在于:
connect()
,服务器进程的listen()
和accept()
)而UDP套接字不需要先建立连接,它在调用socket()
生成一个套接字后,在服务器端调用bind()
绑定一个端口,然后服务器进程挂起于recvfrom()
调用,等待并接收网络中某一客户机的数据请求。而客户端调用sendto()
发送数据请求,同样也挂起于recvfrom()
调用,等待并接收服务器的应答信号。
close()
释放通信链路,但不再发送“断开连接通知”信息来通知服务器端释放通信链路。每一个来自客户端的TCP请求在服务器端都会对应一个 socket。事实上,这个新 socket 的创建实机既不是在 listen 的时候,也不是在 accept 的时候,而是在三次握手成功之后创建的,然后放在对应的全连接队列中。
服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,如果服务器超时还未收到 ACK 会进行 SYN+ACK 的重传,重传的次数由 tcp_synack_retries 值确定,在 CentOS 上这个值等于 5。服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 全连接 队列,等待进程调用 accept 函数时把连接取出来。
accept会阻塞直到3次握手成功为止,也就是说accept发生在三次握手之后(没有accept 3次握手照样成功)。
服务器在 listen 状态的时候可以接收来自客户端的握手请求。当客户端发出的第三次 ack 到达时,服务器创建了新的 sock 对象(socket 的核心),然后加入到了全连接队列中。然后accept的时候,仅仅只是从全连接队列里把 sock 取出来而已。
套接字描述符是一个整数类型的值,就如程序通过文件描述符访问文件一样,套接字描述符是访问套接字的一种路径。每个进程的进程空间里都有一个套接字描述符表(每个进程维护一个单独的套接字描述符表。因此,应用程序可以拥有相同的套接字描述符),该表中存放着套接字描述符和套接字数据结构的对应关系。该表中有一个字段存放新创建的套接字的描述符,另一个字段存放套接字数据结构的地址,因此根据套接字描述符就可以找到其对应的套接字数据结构。每个进程在自己的进程空间里都有一个套接字描述符表但是套接字数据结构都是在操作系统的内核缓冲里。
从某种意义上说,套接字也是文件,所以许多对文件描述符使用的函数,对套接字描述符同样适用。每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。
在redis中,当下次这个client端往Redis服务器发送数据的时候,数据就会被内核拷贝到这个socket的输入缓冲区,然后aeEvent模块监听到这个事件就会调用readQueryFromClient函数。
有一个问题:比如我的程序开了一个监听端口,与客户端建立连接之后,生成了一个新套接字。这时我执行了只关闭监听端口的语句,结果却发现监听端口和已建立的连接仍然存在。我都已经关闭了监听套接字,为什么客户端还可以继续往监听端口发信息?这到底是因为什么呢?新套接字和监听套接字有什么关系呢?
监听套接字就是个牵线指路的,你实质上是跟它指的那个人说话。因为你要找的那个人不可能随时等你来,而监听套接字就是专职等你来问,它回答你要找的人在哪,并唤醒你要找的人,于是通话就建立起来了,就像现实生活中的接线员一样。
也就是说,在连接建立后,客户端用发出连接的那个SOCKET向服务器发数据,是发给服务器新创建的SOCKET,而不是服务器的监听SOCKET。服务器的监听SOCKET永远只是用来接受连接请求。
而新创建的SOCKET的端口其实和监听SOCKET是一样的。一般来说一个应用使用一个端口,但是一个应用一般采用一进程多线程,所以各连接套接字的端口是相同的,哪怕是多进程并行应该也是相同的。可以理解为在程序与程序间不能用同一端口,但是在程序内部不同的Socket还是可以用同一端口的。
如果是随机使用一个新的端口,是一定会被防火墙拦截的。
对于tcp,服务器的连接套接字靠四元组唯一标识(本端同,对端不同),监听套接字因为有且只有一个所以靠二元组唯一标识(仅本端),客户端的套接字靠四元组唯一标识(本端不同,对端同),之所以本机收到信息后,能将信息分配到正确的套接字,是通过判断源IP地址和源端口号,如果没有找到匹配源IP地址和源端口号的连接套接字,就把该信息交给监听套接字;对于udp,发送方发过去就完事了,接收方接受一下就完事了,不存在来回来回来回的反复通信,某方的套接字只为本地的那一方负责(收/发那么一下),所以两端都靠二元组唯一标识即可(仅本端)(强调一下“唯一标识套接字”指的是在某一台主机的4和4.5层的多路复用/多路分解时不混淆地区分、标识不同套接字实例);但是对于某次udp/tcp通信的流向,一定都是四元组,不能把它和套接字混淆。
另外,接收端的应用进程和端口可以是一对一、一对多、多对一的关系,即一个进程可以同时监听多个端口,或多个进程的端口复用。所以,进程和端口并不是像之前想的一定一一对应,端口不同的udp/tcp套接字一定会上交给不同的进程(如80、21,因为协议/服务是不同的则进程一定不同),但端口相同的udp/tcp套接字也可能上交给不同的进程(多进程)、相同进程的不同线程(多线程)。
Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上,如果想要使用 socket 非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。
在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。
针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。
相关函数:
int socket(int domain,int type,int protocol)
domain(协议族):常用的协议族便是IPV4(PF_INET), IPV6(PF_INET6),本地通信协议的UNIX族(PF_LOCAL);
type:数据传输类型;典型数据传输类型:SOCK_DGRAM(数据报套接字/无连接的套接字),SOCK_RAW,SOCK_SEQPACKET,SOCK_STREAM(流格式套接字/面向连接的套接字);
protocal:具体协议,通常为0,表示按给定的域或套接字类型选择默认协议。当对同一域和套接字类型支持多个协议时,可以使用protocol参数选择一个特定协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。
作用:socket() 函数用来创建套接字,返回值就是一个 int 类型的文件描述符。
int bind(int sock, struct sockaddr *addr, socklen_t addrlen)
int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen)
sock 为 socket 文件描述符,addr 为 sockaddr 结构体变量的指针,addrlen 为 addr 变量的大小,可由 sizeof() 计算得出。
作用:服务端用 bind() 函数将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。类似地,客户端也要用 connect() 函数建立连接。
int listen(int sock, int backlog)
sock 为需要进入监听状态的套接字,backlog 为请求队列的最大长度。
请求队列:当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误,对于 Windows,客户端会收到 WSAECONNREFUSED 错误。
作用:对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状态。所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen)
sock 为服务器端套接字,addr 为 sockaddr_in 结构体变量,addrlen 为参数 addr 的长度,可由 sizeof() 求得。
作用:accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sock 是服务器端的套接字,要注意区分。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。
1)linux下数据的接收发送:
ssize_t read(int fd, void *buf, size_t nbytes)
ssize_t write(int fd, const void *buf, size_t nbytes)
fd 为要读取/写入的文件的描述符,buf 为要读取/写入的数据的缓冲区地址,nbytes 为要读取/写入的数据的字节数。
作用:read() 函数会从 fd 文件中读取 nbytes 个字节并保存到缓冲区 buf,成功则返回读取到的字节数(但遇到文件结尾则返回0),失败则返回 -1;write() 函数会将缓冲区 buf 中的 nbytes 个字节写入文件 fd,成功则返回写入的字节数,失败则返回 -1。
2)win下数据的接收发送:
int recv(SOCKET sock, char *buf, int len, int flags)
int send(SOCKET sock, const char *buf, int len, int flags)
sock 为要接收/发送数据的套接字,buf 为要接收/发送的数据的缓冲区地址,len 为要接收/发送的数据的字节数,flags 为发送数据时的选项,一般设置为 0 或 NULL。
int shutdown(int socketfd,int how)
关闭sockfd指向的套接字的how。其中how的取值可以为:SHUT_RD,SHUT_WR,SHUT_RDWR。
与close的区别:close是关闭一个指向文件的文件描述符,其实只是关闭了这个文件描述符对文件表的指针。如果该文件仍有其他文件描 述符引用的话,该文件的V节点表并没有关闭。只有当关闭的文件描述符是最后一个指向文件的文件描述符,V节点才能也被关闭;而shutdown是关闭对一文件的读写等属性,不问有多少个文件描述符对该文件引用。