本博客为2019暑假为学弟学妹讲网络编程的迁移
计算机网络概述
- 我们的信息是如何在计算机网络中从一点到达另一点的?
1.首先,需要一套交流的规则 – 协议
OSI 标准 TCP/IP协议族
IPv4数据报
TCP数据报
传输控制协议(英语:Transmission Control Protocol,缩写:TCP)
是一种面向连接的、可靠的、基于字节流的传输层通信协议
- 所谓可靠传输是指在通讯时常见的丢包,乱序的条件下依然可以保障数据被依序接受,中间不丢数据。是靠序列号,滑动收发窗口, 接收方ACK,重传等机制保障发送的。
- 一个字节流是一种特定的抽象化,一个让实体(entity)可以传输一系列的字节给处在另一端实体的一种通信频道。一般来说这种频道会是双向,不过有时有单向的。在几乎所有的状况,这里的频道都具有所谓可靠的特质;也就是,在另一端会按照正确的顺序出现应该出现的字节(现实生活中有些频道,有时会顺序错误,有时会多出或者失去一些字节)。
- TCP则传输一个没有时间同步的字节流。
- 面向链接:网络系统需要在两台计算机之间发送数据之前先建立连接的一种特性。面向连接网络类似于电话系统,在开始通信前必须先进行一次呼叫和应答。
- 面向连接的服务(connection-oriented service)就是通信双方在通信时,要事先建立一条通信线路,其过程有建立连接、使用连接和释放连接三个过程。
那么TCP链接是如何建立的?
SYN:发起一个新连接。
ACK:确认序号有效。
FIN:释放一个连接
第一次握手:服务器发送位码为SYN=1,随机产生seq number=1234567的数据包到服务器,主机B由SYN=1知道,A要求建立联机;
SYN位表示建立链接
第二次握手:主机B收到请求后要确认联机信息,向A发送ack number=(主机A的seq+1),SYN=1,ACK=1,随机产生seq=7654321的包;
第三次握手:主机A收到后检查ack number是否正确,即第一次发送的seq number+1,以及位码ACK是否为1,若正确,主机A会再发送ack number=(主机B的seq+1),ACK=1,主机B收到后确认seq值与ACK=1则连接建立成功。
完成三次握手,主机A与主机B开始传送数据。
为什么是三次?
让我们看看这三次握手意味着什么?
第一次握手:
Client什么都不能确认
Server确认了对方发送正常
第二次握手:
Client确认:自己发送/接收正常
Server确认:自己接收正常
第三次握手:
Client确认:自己发送/接收正常
Server确认:自己发送/接收正常
断开链接的过程 – 四次挥手
第一次挥手:
Client发送一个FIN,用来关闭Client到Server的数据传送,Client进入FIN_WAIT_1状态。
第二次挥手:
Server收到FIN后,发送一个ACK给Client,Server进入CLOSE_WAIT状态。
第三次挥手:
Server发送一个FIN,用来关闭Server到Client的数据传送,Server进入LAST_ACK状态。
第四次挥手:
Client收到FIN后,Client进入TIME_WAIT状态,发送ACK给Server,Server进入CLOSED状态,完成四次握手。
为什么建立连接是三次握手,而关闭连接却是四次挥手呢?
当收到对方的FIN报文时,仅表示对方不再发送数据但还能接收收据,我们也未必把全部数据都发给了对方,所以我们可以立即close,也可以发送一些数据给对方后,再发送FIN报文给对方表示同意关闭连接。因此我们的ACK和FIN一般会分开发送。
ps:可以了解一下半关闭 shutdown()
TCP输出 --某个应用进程写数据到一个TCP套接字中时发生的步骤
当某个应用进程write时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。
如果该套接字的发送缓冲区容不下该应用进程的所有数据,该应用进程将被投入睡眠。(假设该套接字是阻塞的)
内核此时将不从write系统调用中返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个TCP套接字write调用成功返回仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端TCP或应用进程已接受到数据。
MTU(最大传输单元)[UNP.p46]
在两个主机之间最小的MTU被称为路径MTU(path MTU)。1500字节的以太网MTU是当今最常见的路径MTU。当一个IP数据报将从某个接口送出时,如果它的大小超过相应链路的MTU,IPv4和IPv6都将执行分片。这些片段在到达最终目的地之前通常不会被重组。IPv4主机对其产生的数据报进行分片,IPv4路由器则对其转发的数据报执行分片。
TCP有一个MSS(最大分节大小),用于向对端TCP通告在每个分节中能发送的最大TCP数据量。
总而言之,发送端发送一个数据包,对端可能会接受好几次。并不是一一对应的。
建立TCP连接就好比一个电话系统[Nemeth 1997]。
UNP中一个形象的比喻
socket函数等同于有电话可用。
bind函数是在告诉别人你的电话号码,这样他们可以呼叫你。
listen函数是打开电话振铃,这样当有一个外来呼叫到达时,你就可以听到。(被动)
connect函数要求我们知道对方的电话号码并拨打它。(主动)
accept函数发生在被呼叫的人应答电话之时。由accept返回客户的标识(即客户的IP地址和端口号)类似于让电话机的呼叫者ID功能部件显示呼叫者的电话号码。然而两者的不同之处在于accept只在连接建立之后返回客户的标识,而呼叫者ID功能部件却在我们选择应答或不应答之前显示呼叫者的电话号码。
网络编程相关API
socket介绍
socket可以看成是用户进程与内核网络协议栈的编程接口。它屏蔽了底层通信的细节,让我们更方便的通信。socket不仅可以用于本机的进程间通信,还可以用于网络上不同主机的进程间通信。
IPv4套接口地址结构通常也称为“网际套接字地址结构”,它以“sockaddr_in”命名,定义在头文件<netinet/in.h>中
struct sockaddr_in {
uint8_t sin_len;
sa_family_t sin_family;
in_port_t sin_port;
structin_addr sin_addr;
char sin_zero[8];
};
sin_len
:整个sockaddr_in结构体的长度,在4.3BSD-Reno版本之前的第一个成员是sin_family.
sin_family
:指定该地址家族,在这里必须设为AF_INET
sin_port
:端口
sin_addr
:IPv4的地址; 存放IP地址的结构体
sin_zero
:暂不使用,一般将其设置为0
为了能在不同协议之间进行通信,出现了通用地址结构来指定与套接字关联的地址
struct sockaddr {
uint8_t sin_len;
sa_family_t sin_family;
char sa_data[14];
};
sin_len
:整个sockaddr结构体的长度
sin_family
:指定该地址家族
sa_data
:由sin_family决定它的形式。
bind(sockfd,(struct sockaddr *) &serv,sizeof(serv));
从应用程序的开发人员观点看,这些通用套接字地址结构唯一的用途就是对指向特定与协议的套接字地址结构指针执行类型强制转换
从内核的角度看,使用指向通用的套接字地址结构的原因:内核必须取调用者的指针,把它强转为通用地址结构,检查其中sa_family字段的值来确定这个结构的真实类型。
需要特别提到的两个函数
listen把一个未连接的套接字转换成一个被动套接字(监听套接字)
内核为任何一个给定的监听套接字维护两个队列:
accept从已完成链接队列队头返回下一个已完成链接
几个概念
- 进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。 - IO模式
刚才说了,对于一次IO访问(以fread举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
-
等待数据准备 (Waiting for the data to be ready)
-
将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
正式因为这两个阶段,linux系统产生了下面五种网络模式的方案:
阻塞 I/O(blocking IO)非阻塞 I/O(nonblocking IO)
I/O 多路复用( IO multiplexing)信号驱动 I/O( signal driven IO)
异步 I/O(asynchronous IO)
I/O多路复用
–I/O multiplexing
两个图
IO多路复用指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。
IO多路复用适用如下场合:
-
当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
-
当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
-
如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
-
如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
-
如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
select
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
返回值:
若有就绪描述符返回其数目,若超时则为0,若出错则为-1
参数
maxfdp1
指定待测试的描述字个数。
fd_set
则是配合select模型的重点数据结构,用来存放描述符的集合。
timeout
表示告知内核等待所指定描述字中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。NULL一直等,0不等
struct timeval{
long tv_sec; //seconds
long tv_usec; //microseconds
};
FD_ZERO(&readfds); //清空集合
FD_SET(fd1,&readfds); //设置监听的fd
FD_SET(fd2,&readfds);
timeout.tv_sec = 4;
timeout.tv_usec = 0;
retval = select(maxfd,&readfds,NULL,NULL,&timeout);
if(retval == -1)
//错误处理
if(retval > 0)
{
if(FD_ISSET(fd1,&readfds))
处理函数(fd1);
if(FD_ISSET(fd2,&readfds))
处理函数(fd2);
}
select 缺点
到这里,我们有三个问题需要解决:
-
被监控的fds集合限制为1024,1024太小了,我们希望能够有个比较大的可监控fds集合
-
fds集合需要从用户空间拷贝到内核空间的问题,我们希望不需要拷贝
-
当被监控的fds中某些有数据可读的时候,我们希望通知更加精细一点,就是我们希望能够从通知中得到有可读事件的fds列表,而不是需要遍历整个fds来收集。
poll
select遗留的三个问题中,问题(1)是用法限制问题,问题(2)和(3)则是性能问题。poll和select非常相似,poll并没着手解决性能问题,poll只是解决了select的问题(1)fds集合大小1024限制问题。
下面是poll的函数原型,poll改变了fds集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的fds集合限制远大于select的1024。poll虽然解决了fds集合大小1024的限制问题,但是,它并没改变大量描述符数组被整体复制于用户态和内核态的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。poll随着监控的socket集合的增加性能线性下降,poll不适合用于大并发场景。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
epoll
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
使用起来很清晰,首先要调用epoll_create
建立一个epoll fd。参数size是内核保证能够正确处理的最大文件描述符数目(现在内核使用红黑树组织epoll相关数据结构,不再使用这个参数)。
epoll_ctl
可以操作上面建立的epoll fd,例如,将刚建立的socket fd加入到epoll中让其监控,或者把 epoll正在监控的某个socket fd移出epoll,不再监控它等等。
epoll_wait
在调用时,在给定的timeout时间内,当在监控的这些文件描述符中的某些文件描述符上有事件发生时,就返回用户态的进程。
ps:中间层ready_list
LT 与 ET
LT(level triggered) 同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.
ET(edge-triggered) 只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)