解开Socket编程的面纱
Socket编程
C++基于TCP和UDP的通信
Linux 下socket通信终极指南(附TCP、UDP完整代码)
UDP Socket全攻略
1. 网络中进程之间如何通信?
本地的进程间通信(IPC)有很多种方式:
- 消息传递(管道、FIFO、消息队列)
- 同步(互斥量、条件变量、读写锁、文件和写记录锁、信号量)
- 共享内存(匿名的和具名的)
- 远程过程调用(Solaris门和Sun RPC)
1.1 如何唯一标识一个进程?
在本地可以通过进程 PID
,来唯一标识一个进程.
但在网络中, PID
不能唯一标识一个进程.
TCP/IP
协议族通过 网络层的ip地址
可以唯一标志网络中的一台主机.
而 传输层的 协议 + 端口
可以唯一标志主机中的应用程序(进程).
这样,通过 三元组(ip地址,协议,端口)
可以标识网络中的一个进程.
那么网络中进程之间就可以通过这个标识进行交互通信了.
使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字(socket)实现网络进程之间的通信.
几乎所有的应用程序都是采用socket.
2. Socket
2.1 Socket在哪里?
2.2 Socket是什么?
Socket起源于Unix,而Unix/Linux基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作.
Socket即是 一种特殊的文件 一些Socket函数就是对其进行的操作(读/写IO、打开、关闭).
Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。
在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
3. Socket的基本操作
既然socket是“open—write/read—close”模式的一种实现,那么socket就提供了这些操作对应的函数接口。
下面以TCP为例,介绍几个基本的socket接口函数。
3.1 socket()函数
int socket(int domain, int type, int protocol);
socket函数对应与普通文件的打开操作.
普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它 唯一标识一个socket 。
这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:
domain
: 即协议域type
: socket类型. 如: SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等protocol
: 指定协议. 常用的协议有,IPPROTO_TCP
、IPPTOTO_UDP
、IPPROTO_SCTP
、IPPROTO_TIPC
等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议
当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。
如果想要给它赋值一个地址,就必须调用 bind()
函数,否则就当调用 connect()、listen()时系统会自动随机分配一个端口。
3.2 bind()函数
bind()函数把一个地址族中的 特定地址
赋给socket。
例如: AF_INET
、AF_INET6
就是把一个ipv4或ipv6地址和端口号组合赋给socket。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函数的三个参数分别是:
-
sockfd
:即socket描述字,它是通过socket()
函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。 -
addr
: 一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同.- 通常服务器在启动的时候都会绑定一个 众所周知的地址(如ip地址+端口号),用于提供服务.客户就可以通过它来接连服务器.
- 而客户端就不需要指定. 有系统自动分配一个端口号和自身的ip地址组合.
- 这就是为什么 通常服务器端在listen之前会调用bind(), 而客户端不会调用
bind()
. 而是在connect
时由系统随机分配一个.
网络字节序与主机字节序
大小端问题
3.3 listen() 与 connect() 函数
如果作为一个服务器, 在调用 socket()
bind()
之后就会调用 listen()
来监听这个 socket
.
如果客户端这时调用 connect()
发生连接请求. 服务器端就会接受这个请求.
int listen(int sockfd, int backlog);
- listen函数的第一个参数即为要监听的 socket 描述子.
- 第二个参数为相应 socket 可以排队的最大连接个数.
- socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求.
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- connect函数的第一个参数即为客户端的 socket描述字.
- 第二参数为服务器的socket地址.
- 第三个参数为 socket地址的长度.
- 客户端通过调用 connect函数来建立与TCP服务器的连接.
3.4 accept() 函数
TCP服务器端依次调用 socket()
bind()
listen()
之后. 就会监听指定的socket地址了.
TCP客户端依次调用 socket()
connect()
之后就想TCP服务器发送一个连接请求.
TCP服务器监听到这个请求之后.就会调用 accept()
函数取接收请求.
这样连接就建立好了.
之后. 就可以开始网络I/O操作了. 即类同于普通文件的读写I/O操作.
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- accept函数的第一个参数为服务器的socket描述子.
- 第二个参数为指向
struct sockaddr*
的指针. 用于返回客户端的协议地址. - 第三个参数为协议地址的长度.
- 如果accept成功. 那么其返回值是由内核自动生成的一个全新的描述字.代表与返回客户的TCP连接.
- 注意!!! accept的第一个参数为 服
务器的socket描述字
! 是服务器开始调用 socket()函数生成的. 成为监听socket描述字. 而accept函数返回的是已连接的socket描述字
. - 一个服务器通常仅仅只创建一个监听的描述字. 它在该服务器的生命周期一直存在.
3.5 read() write()等函数
当服务器与客户端建立好了TCP连接. 可以调用网络I/O进行读写操作.即实现网络中不同进程间的通信.
网络I/O操作有以下几组:
- read() / write()
- recv() / send()
- readv() / writev()
- recvmsg()/sendmsg()
- recvfrom()/sendto()
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
read函数 负责从fd中读取内容.
当读成功时,read返回实际所读的字节数.
如返回的值是0,表是已经读到了文件的结尾了.
小于0表示出现了错误.如果错误为EINTR说明读是由中断引起的,如果是ECONNREST表示网络连接出了问题。
write函数 将buf中的nbytes字节内容写入文件描述符fd.
成功返回写的字符数.
失败返回01.
3.6 close() 函数
在服务器和客户端建立连接之后. 会进行一些读写操作. 完成了读写操作就要关闭相应的socket描述字.好比打开的文件要调用fclose关闭打开的文件.
int close(int fd);
close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。
该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。
注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
4. Socket中TCP的三次握手建立连接详解
我们知道 TCP建立连接的 “三次握手”. 即交换三个分组.大致流程如下:
- 客户端向服务器发送一个SYN J
- 服务器向客户端响应一个SYN K,并对SYN J进行确认ACK J+1
- 客户端再想服务器发一个确认ACK K+1
只有就完了三次握手,但是这个三次握手发生在socket的那几个函数中呢?
从图中可以看出. 当客户端调用 connect
时,就触发了连接请求. 像服务器发送 SNY J包.这时, connect
进入阻塞状态;
服务器监听到连接请求. 即收到 SYN J包.调用 accept
函数接收请求向客户端发送 SYN K和 ACK J+1包.这时候 accept
也进入阻塞状态.
客户端收到服务器发送的 SYN K,ACK J+1包.这时, connect
函数返回. 并对 SYN K进行确认. 服务器收到 ACK K+1时. accept返回! 三次握手完毕. 连接建立!
总结:客户端的connect在三次握手的第二个次返回,而服务器端的accept在三次握手的第三次返回。
5. socket中TCP的四次握手释放连接详解
socket中的四次握手释放连接的过程:
- 当某个应用程序首先调用
close
主动关闭连接时. TCP会发生一个 FIN M; - 另一端收到FIN M之后.执行被关闭. 对这个FIN进行确认. 它的接收也作为文件结束符传递给应用进程,因为FIN的接收意味着应用进程在相应的连接上再也接收不到额外数据;
- 一段时间后. 接受到文件结束符的服务器用 close关闭它的socket. 值导致它的TCP也发送一个FIN N.
- 接受到FIN源发送端TCP对它进行确认.
4 TCP的Socket编程过程
TCP Socket服务器端流程如下:
1.创建serverSocket
2.初始化 serverAddr(服务器地址)
3.将socket和serverAddr 绑定 bind
4.开始监听 listen
5.进入while循环,不断的accept接入的客户端socket,进行读写操作write和read
6.关闭serverSocket
TCP Socket客户端流程:
1.创建clientSocket
2.初始化 serverAddr
3.链接到服务器 connect
4.利用write和read 进行读写操作
5.关闭clientSocket
5 UDP的socket编程过程
5.1 普通UDP
UDP协议不能保证数据通信的可靠性,容易出现丢包现象,但是开销更低,编起来也更加简单
UDP Socket服务器流程:
1.创建serverSocket
2.设置服务器地址 serverAddr
3.将serverSocket和serverAddr绑定 bind
4.开始进行读写 sendto和recvfrom
5.关闭serverSocket
UDP Socket客户端流程
1.创建clientSocket
2.设置服务器地址 serverAddr
3.可选 设置clientAddr并和clientSocket(一般不用绑定)
4.进行发送操作 sendto
5.关闭clientSocket
注:
- 客户端要发起一次请求,仅仅需要两个步骤(socket和sendto);
- 而服务器端也仅仅需要三个步骤即可接收到来自客户端的消息(socket、bind、recvfrom);
5.2 高级UDP Socket编程
UDP的connect函数
什么?UDP也有conenct?connect不是用于TCP编程的吗?
是的,UDP网络编程中的确有connect函数,但它仅仅用于表示确定了另一方的地址,并没有其他含义。
有了以上人士后. 我们可以知道 UDP套接字有以下区分:
- 未连接的UDP套接字
- 已连接的UDP套接字
对于未连接的套接字,也就是我们常用的的UDP套接字
- 客户端使用的是sendto/recvfrom进行信息的收发,
- 目标主机的IP和端口是在调用sendto/recvfrom时确定的;
在一个 未连接的UDP套接字
上给两个数据报调用sendto函数内核将执行以下六个步骤:
1)连接套接字
2)输出第一个数据报
3)断开套接字连接
4)连接套接字
5)输出第二个数据报
6)断开套接字连接
对于 已连接的UDP套接字 ,必须先经过connect来向目标服务器进行指定,
然后调用read/write进行信息的收发,目标主机的IP和端口是在connect时确定的,
也就是说,一旦conenct成功,我们就 只能对该主机进行收发信息了
已连接的UDP套接字给两个数据报调用write函数内核将执行以下三个步骤:
1)连接套接字
2)输出第一个数据报
3)输出第二个数据报
当应用进程知道 给同一个目的地址的端口号发送多个数据报时,显示套接字效率更高。
下面给出带connect函数的UDP通信框架
UDP报文丢失问题
因为UDP自身的特点. 决定了UDP会相对于TCP存在一些难以解决的问题.
第一个就是报文丢失的问题.
- 如果客户端发送的数据丢失. 服务器会一直等待. 直到客户端的合法数据过来.
- 如果服务器的响应在中间被路由丢弃.则客户端会一直阻塞.直到服务器数据过来.
防止这样的永久阻塞的一般方法是给客户端的 recvfrom
调用设置一个超时.大概有这么两种方法:
-
使用信号SIGALRM为recvfrom设置超时。首先我们为SIGALARM建立一个信号处理函数,并在每次调用前通过alarm设置一个5秒的超时。如果recvfrom被我们的信号处理函数中断了,那就超时重发信息;若正常读到数据了,就关闭报警时钟并继续进行下去。
-
使用
select
为recvfrom
设置超时.
设置select函数的第五个参数即可。
UDP报文乱序问题
所谓乱序就是发送数据的顺序和接收数据的顺序不一致,例如发送数据的顺序为A、B、C,但是接收到的数据顺序却为:A、C、B。产生这个问题的原因在于,每个数据报走的路由并不一样,有的路由顺畅,有的却拥塞,这导致每个数据报到达目的地的顺序就不一样了。UDP协议并不保证数据报的按序接收。
解决这个问题的方法就是在发送端在发送数据时候加入数据报的序号.
这样接收端接收到报文后.可以先检查数据包的序号.并将它们进行排序.形成有序的数据报.
UDP流量控制问题
众所周知,TCP有滑动窗口进行流量控制和拥塞控制.
反观UDP因为其特点无法做到。UDP接收数据时直接将数据放进缓冲区内,
- 如果用户没有及时将缓冲区的内容复制出来放好的话,后面的到来的数据会接着往缓冲区放,
- 当缓冲区满时,后来的到的数据就会覆盖先来的数据而造成数据丢失(因为内核使用的UDP缓冲区是环形缓冲区)。
因此,一旦发送方在某个时间点爆发性发送消息,接收方将因为来不及接收而发生信息丢失。
解决的方法一般采用增大 UDP缓存区. 使得接收方的接受能力大于发送方的发送能力.
int n = 220 * 1024; //220kB
setsocketopt(sockfd, SOL_SOCKET, SO_RCVBUF, &n, sizeof(n));
这样我们就把接收方的接收队列扩大了,从而尽量避免丢失数据的发生。