其实我们网络之间的通信本质上还是进程间通信,我们将网络看成他们进程之间的临界资源,实际上就是本地进程与远端服务器进程间的通信
源IP地址和目的IP地址
端口号
端口号是一个 2 字节 16 位的整数 ;端口号用来标识一个进程 , 告诉操作系统 , 当前的这个数据要交给哪一个进程来处理 ;IP 地址 + 端口号能够标识网络上的某一台主机的某一个进程 ;一个端口号只能被一个进程占用
我们的IP地址+端口号就构成了我们的套接字(socket)
端口号 和 进程ID
我们的端口号只是在网络中标记进程使用的,每一个进程都有PID,但并不是所有的进程都会有端口号,端口号只是参与网络通信的进程才会有,所以,一个进程只能有一个PID,而一个进程可以有多个端口号
TCP协议和UDP协议
此处我们先对 TCP(Transmission Control Protocol 传输控制协议 ) 有一个直观的认识 ; 后面我们再详细讨论 TCP 的一些细节问题传输层协议有连接可靠传输面向字节流
此处我们也是对 UDP(User Datagram Protocol 用户数据报协议 ) 有一个直观的认识 ; 后面再详细讨论传输层协议无连接不可靠传输面向数据报
网络字节序
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出 ;接收主机把从网络上接到的字节依次保存在接收缓冲区中 , 也是按内存地址从低到高的顺序保存 ;因此 , 网络数据流的地址应这样规定 : 先发出的数据是低地址 , 后发出的数据是高地址 .TCP/IP 协议规定 , 网络数据流应采用大端字节序 , 即低地址高字节 .不管这台主机是大端机还是小端机 , 都会按照这个 TCP/IP 规定的网络字节序来发送 / 接收数据 ;如果当前发送主机是小端 , 就需要先将数据转成大端 ; 否则就忽略 , 直接发送即可
我们发送的数据主要是IP地址与端口号
字节序转换函数
这些函数名很好记 ,h 表示 host,n 表示 network,l 表示 32 位长整数 ,s 表示 16 位短整数。例如 htonl 表示将 32 位的长整数从主机字节序转换为网络字节序 , 例如将 IP 地址转换后准备发送。如果主机是小端字节序 , 这些函数将参数做相应的大小端转换然后返回 ;如果主机是大端字节序 , 这些 函数不做转换 , 将参数原封不动地返回
socket编程接口
socket常见API
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
domain:协议簇里的一个,一般AF_INET对应IPv4协议。
type:类型常见两种,面向字节流(TCP)对应SOCK_STREAM。面向数据报对应SOCK_DGRAM(UDP)。
protocol:协议,默认0,让系统选择。
返回值:
socket的返回值是一个文件描述符。失败返回-1。系统也将网络描述成了一个文件。
这里返回的是一个文件描述符,说明创建了一个文件,进程通过该文件来找到soket来对其进行控制。
socket和文件之间的关系
我们通过sock用来保存接收与要发送的数据,通过进程可以找到sock,通过sock可以找到目标进程
Socket中的sk指向的是一个sock类型,对于tcp协议,它实际指向的是tcp_sock,想指向sock内容,要将tcp_sock强转成sock
绑定端口号与IP地址,将端口号与IP地址和创建的网络资源绑定
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,socklen_t address_len);
参数:
sockfd:socket的返回值。
addr :结构体里面保存了IP地址和端口号。
addlen:addr大小
返回值:失败返回-1,成功返回0。
其他接口
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
sockaddr结构
socket API 是一层抽象的网络编程接口 , 适用于各种底层网络协议 , 如 IPv4 、 IPv6, 以及后面要讲的 UNIX DomainSocket. 然而 , 各种网络协议的地址格式并不相同
IPv4 和 IPv6 的地址格式定义在 netinet/in.h 中 ,IPv4 地址用 sockaddr_in 结构体表示 , 包括 16 位地址类型 , 16位端口号和32 位 IP 地址 .IPv4 、 IPv6 地址类型分别定义为常数 AF_INET 、 AF_INET6. 这样 , 只要取得某种 sockaddr 结构体的首地址 ,不需要知道具体是哪种类型的sockaddr 结构体 , 就可以根据地址类型字段确定结构体中的内容 .socket API 可以都用 struct sockaddr * 类型表示 , 在使用的时候需要强制转化成 sockaddr_in; 这样的好处是程序的通用性, 可以接收 IPv4, IPv6, 以及 UNIX Domain Socket 各种类型的 sockaddr 结构体指针做为参数
在我们的使用中,我们使用最多的是struct sockaddr_in这个结构体,它是用来保存我们的主机IP地址与端口号的,保存方式为网络字节序
sockaddr_in 结构
in_addr结构
in_addr用来表示一个IPv4的IP地址. 其实就是一个32位的整数
q:我们的socket API是一层抽象的网络接口,是如何实现与各种协议的?
其实我们的函数一般都有一个参数来作为标记
我们在跨网络通信时,使用struck_sockaddr_in,我们只需要定义一个struct sockaddr_in对象,向里面放入对应的端口号与IP地址,再强转为struct sockaddr*作为参数传递给接口即可
而我们的三个结构体又是如何分别你这些的呢?
我们通过上面的图也可以看到我们结构体的上部分都有一个16位地址类型,我们判断该数据如果在sockaddr_in中,就是这个类型,并执行sockaddr_in对应方法,如果在sockaddr_un中,就执行sockaddr_un对应的方法,这也是我们多态的体现
简单的UDP网络程序
我们的UDP协议是无连接,面向数据报的协议
服务器端:创建套接字,将IP地址与端口号与套接字进行绑定,可以接收发输出,客户端需要知道服务器的IP地址与端口号进行绑定
客户端:创建套接字,客户端不需要将IP地址与端口号进行绑定,直接发送与接收数据,客户端需要知道服务器的IP地址与进程端口号
为什么客户端不需要绑定,而服务器端则需要绑定呢?
我们先来回答一下为什么服务器端是需要绑定的,因为服务器端一旦进程跑起来通常是不会终止的,一般这个端口号是确定的,如果换来换去,每次客户端向服务器发出的数据还都需要绑定新的服务器IP与端口号,太麻烦了,所以我们的服务器一般不轻易的修改端口号,因为进程与端口号强相关,所以我们需要绑定一个确定的端口号
而为什么客户端不需要绑定,那是因为在我们计算机中,会跑很多进程,我们并不能确定哪个进程是被使用的,如果不同的客户端绑定了相同的端口号,则会导致客户端无法启动,因为我们客户端进程需要唯一的端口号,但我们并不确定是哪个端口号,系统会自动绑定端口号给进程,只有我们的操作系统知道哪些端口号可以绑定
收发数据时用到的函数
UDP接收数据recvform
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen)
sockfd:套接字描述符
buf:将接收的数据存放到buf中
len:最大接受能力
falgs:0:接受阻塞;
src_addr:表示数据从哪个地址信息结构来的(消息从哪个ip与端口来的)
addrlen:地址信息长度,输入性参数
UDP发送接口sendto
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
sockfd:套接字描述符
buf:要发送的数据
len:发送数据的长度
flags:0:阻塞发送
dest_addr:目标主机的地址信息结构(IP,port)
addrlen: 目标主机地址信息结构长度
返回值:成功返回具体发送的字节数量,失败返回1
UDP关闭接口close
close(int sockfd);
代码:
客户端:
#include<iostream>
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<string.h>
#include<stdlib.h>
using namespace std;
int main()
{
SockFd=socket(AF_INET,SOCK_DGRAM,0);
if(SockFd<0)
{
cout<<"套接字创建失败!"<<endl;
}
cout<<"SockFd:"<<SockFd<<endl;
while(1)
{
char buf[1024]="i am client!";
struct sockaddr_in dest_addr;
dest_addr.sin_family=AF_INET;
dest_addr.sin_port=htons(20000);
dest_addr.sin_addr.s_addr=inet_addr("1.14.165.138");
sendto(SockFd,buf,strlen(buf),0,(struct sockaddr*)&dest_addr,sizeof(dest_addr));
memset(buf,'\0',sizeof(buf));
struct sockaddr_in peer_addr;
socklen_t len=sizeof(peer_addr);
ssize_t rece_size=recvfrom(SockFd,buf,sizeof(buf)-1,0,(struct sockaddr*)&peer_addr,&len);
if(rece_size<0)
{
continue;
}
cout<<"recv msg:"<<buf<<" from "<<inet_ntoa(peer_addr.sin_addr)<<" "<<ntohs(peer_addr.sin_port)<<endl;
sleep(1);
}
close(SockFd);
return 0;
}
服务器端:
#include<iostream>
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<string.h>
#include<stdlib.h>
using namespace std;
int main()
{
int SockFd=socket(AF_INET,SOCK_DGRAM,0);
if(SockFd<0)
{
cout<<"套接字创建失败!"<<endl;
}
cout<<"SockFd:"<<SockFd<<endl;
struct sockaddr_in addr;
addr.sin_port=htons(20000);
addr.sin_family=AF_INET;
addr.sin_addr.s_addr=inet_addr("172.16.0.9");
int ret=bind(SockFd,(struct sockaddr*)&addr,sizeof(addr));
if(ret<0)
{
cout<<"绑定失败!"<<endl;
return 0;
}
while(1)
{
char buf[1024]={0};
struct sockaddr_in peer_addr;
socklen_t len=sizeof(peer_addr);
ssize_t rece_size=recvfrom(SockFd,buf,sizeof(buf)-1,0,(struct sockaddr*)&peer_addr,&len);
if(rece_size<0)
{
continue;
}
cout<<"recv msg:"<<buf<<" from "<<inet_ntoa(peer_addr.sin_addr)<<" "<<ntohs(peer_addr.sin_port)<<endl;
memset(buf,'\0',sizeof(buf));
sprintf(buf,"welcome client %s:%d\n",inet_ntoa(peer_addr.sin_addr),ntohs(peer_addr.sin_port));
sendto(SockFd,buf,strlen(buf),0,(struct sockaddr*)&peer_addr,sizeof(peer_addr));
}
close(SockFd);
return 0;
}
TCP协议通信流程
TCP通信相关函数
int socket(int domain, int type, int protocol);
socket() 打开一个网络通讯端口 , 如果成功的话 , 就像 open() 一样返回一个文件描述符 ;应用程序可以像读写文件一样用 read/write 在网络上收发数据 ;如果 socket() 调用出错则返回 -1;对于 IPv4, family 参数指定为 AF_INET;对于 TCP 协议 ,type 参数指定为 SOCK_STREAM, 表示面向流的传输协议protocol 参数的介绍从略 , 指定为 0 即可
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
服务器程序所监听的网络地址和端口号通常是固定不变的 , 客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接; 服务器需要调用 bind 绑定一个固定的网络地址和端口号 ;bind() 成功返回 0, 失败返回 -1 。bind() 的作用是将参数 sockfd 和 myaddr 绑定在一起 , 使 sockfd 这个用于网络通讯的文件描述符监听myaddr 所描述的地址和端口号 ;前面讲过 ,struct sockaddr * 是一个通用指针类型 ,myaddr 参数实际上可以接受多种协议的 sockaddr 结构体, 而它们的长度各不相同 , 所以需要第三个参数 addrlen 指定结构体的长度
1. 将整个结构体清零 ;2. 设置地址类型为 AF_INET;3. 网络地址为 INADDR_ANY, 这个宏表示本地的任意 IP 地址 , 因为服务器可能有多个网卡 , 每个网卡也可能绑定多个IP 地址 , 这样设置可以在所有的 IP 地址上监听 , 直到与某个客户端建立了连接时才确定下来到底用哪个IP 地址 ;4. 端口号为 SERV_PORT, 我们定义为 9999;
int listen(int sockfd, int backlog);
listen() 声明 sockfd 处于监听状态 , 并且最多允许有 backlog 个客户端处于连接等待状态 , 如果接收到更多的连接请求就忽略, 这里设置不会太大 ( 一般是 5), 具体细节同学们课后深入研究 ;listen() 成功返回 0, 失败返回 -1;
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)
三次握手完成后 , 服务器调用 accept() 接受连接 ;如果服务器调用 accept() 时还没有客户端的连接请求 , 就阻塞等待直到有客户端连接上来 ;addr 是一个传出参数 ,accept() 返回时传出客户端的地址和端口号 ;如果给 addr 参数传 NULL, 表示不关心客户端的地址 ;addrlen 参数是一个传入传出参数 (value-result argument), 传入的是调用者提供的 , 缓冲区 addr 的长度以避免缓冲区溢出问题, 传出的是客户端地址结构体的实际长度 ( 有可能没有占满调用者提供的缓冲区
三次握手与四次握手
这里我们先来初步了解一下三次握手与四次握手
三次握手是客户端向服务端发出链接请求时发生的
调用 socket, 创建文件描述符 ;调用 bind, 将当前的文件描述符和 ip/port 绑定在一起 ; 如果这个端口已经被其他进程占用了 , 就会 bind 失败;调用 listen, 声明当前这个文件描述符作为一个服务器的文件描述符 , 为后面的 accept 做好准备 ;调用 accecpt, 并阻塞 , 等待客户端连接过来 ;
调用 socket, 创建文件描述符 ;调用 connect, 向服务器发起连接请求 ;connect 会发出 SYN 段并阻塞等待服务器应答 ; ( 第一次 )服务器收到客户端的 SYN, 会应答一个 SYN-ACK 段表示 " 同意建立连接 "; ( 第二次 )客户端收到 SYN-ACK 后会从 connect() 返回 , 同时应答一个 ACK 段 ; ( 第三次 )
我们可以以男女生表白的情景来类比这个过程,男生发出表白请求,女生答应,男女双方建立恋爱关系
四次握手是客户端向服务端发出断开链接请求时发生的
建立连接后 ,TCP 协议提供全双工的通信服务 ; 所谓全双工的意思是 , 在同一条连接中 , 同一时刻 , 通信双方可以同时写数据; 相对的概念叫做半双工 , 同一条连接在同一时刻 , 只能由一方来写数据 ;服务器从 accept() 返回后立刻调 用 read(), 读 socket 就像读管道一样 , 如果没有数据到达就阻塞等待 ;这时客户端调用 write() 发送请求给服务器 , 服务器收到后从 read() 返回 , 对客户端的请求进行处理 , 在此期间客户端调用read() 阻塞等待服务器的应答 ;服务器调用 write() 将处理结果发回给客户端 , 再次调用 read() 阻塞等待下一条请求 ;客户端收到后从 read() 返回 , 发送下一条请求 , 如此循环下去
如果客户端没有更多的请求了 , 就调用 close() 关闭连接 , 客户端会向服务器发送 FIN 段 ( 第一次 );此时服务器收到 FIN 后 , 会回应一个 ACK, 同时 read 会返回 0 ( 第二次 );read 返回之后 , 服务器就知道客户端关闭了连接 , 也调用 close 关闭连接 , 这个时候服务器会向客户端发送一个FIN; ( 第三次 )客户端收到 FIN, 再返回一个 ACK 给服务器 ; ( 第四次)
我们可以以男女分手的例子来类比断开连接,男生向女生说分手,女生答应,女生气不过再对男生说分手,男生答应,自此断开联系
int connect(int sockfd,const struct sockaddr *addr,socklen_t addrlen);
sockfd:套接字描述符addr:要链接的服务端的地址信息结构服务端的ip地址服务端的端口addrlen:地址信息的结构长度返回值:0:成功-1:失败
客户端需要调用connect() 连接服务器 ;connect 和 bind 的参数形式一致 , 区别在于 bind 的参数是自己的地址 , 而 connect 的参数是对方的地址 ;connect() 成功返回 0, 出错返回 -1
send接口
ssize_t send(int sockfd,const void *buf,size_t len,int flags)
sockfd:套接字描述符
客户端:socket函数的返回值
服务端:accept函数的返回值(切记不是侦听套接字)
buf:待要发送的数据
len:数据长度
flags:标志位
0:阻塞发送
MGS PEEK:发送紧急数据(带外数据)
返回值:-1:发送失败
>0:实际发送的字节数量
TCP接受接口recv接口
ssize_t reev(int sockfd, void *buf, size_t len, int flags);
sockfd:套接字描述符客户端:socket函数的返回值服务端:accept函数的返回值buf:将从tcp接收缓冲区当中接收的数据保存在buf中len:buf最大的接受能力flag:0:阻塞接收返回值:<0:函数调用出错==0:对端关闭连接>0:接收到的字节数量
关闭接口close
close(int sockfd);
代码:
客户端:创建套接字,发起连接connect,发送和接收:
#include<unistd.h>
#include<string.h>
#include<iostream>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<error.h>
#include<stdio.h>
using namespace std;
int main()
{
int sockfd=socket(AF_INET,SOCK_STREAM,0);
if(sockfd<0)
{
perror("socket");
return 0;
}
cout<<sockfd<<endl;
struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port=htons(20000);
addr.sin_addr.s_addr=inet_addr("1.14.165.138");
// addr.sin_addr.s_addr=inet_addr("0.0.0.0");这个地址包含所有本地网卡地址
int ret=connect(sockfd,(struct sockaddr*)&addr,sizeof(addr));
if(ret<0)
{
perror("connect");
return 0;
}
if(ret<0)
{
perror("connect");
return 0;
}
while(1)
{
char buf[1024]="i am client呀呀呀!";
send(sockfd,buf,strlen(buf),0);
memset(buf,'\0',sizeof(buf));
//接收
ssize_t recv_size=recv(sockfd,buf,sizeof(buf)-1,0);
if(recv_size<0)
{
perror("recv");
return 0;
}
else if(recv_size==0)
{
cout<<"close peer connect"<<endl;
close(sockfd);
continue;
}
cout<<"buf:"<<buf<<endl;
sleep(1);
}
return 0;
}
服务端:创建监听套接字,绑定地址信息,监听能接受新连接accept,接收,发送
#include<unistd.h>
#include<string.h>
#include<iostream>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<error.h>
#include<stdio.h>
using namespace std;
int main()
{
int listen_sock=socket(AF_INET,SOCK_STREAM,0);
if(listen_sock<0)
{
perror("socket");
return 0;
}
cout<<listen_sock<<endl;
struct sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_port=htons(20000);
addr.sin_addr.s_addr=inet_addr("172.16.0.9");
// addr.sin_addr.s_addr=inet_addr("0.0.0.0");这个地址包含所有本地网卡地址
int ret=bind(listen_sock,(struct sockaddr*)&addr,sizeof(addr));
if(ret<0)
{
perror("bind");
return 0;
}
ret=listen(listen_sock,1);
if(ret<0)
{
perror("listen");
return 0;
}
struct sockaddr_in cli_addr;
socklen_t cli_addrlen=sizeof(cli_addr);
int newsockfd=accept(listen_sock,(struct sockaddr*)&cli_addr,&cli_addrlen);
if(newsockfd<0)
{
perror("accept");
return 0;
}
cout<<"i accept new connect form client:"<<inet_ntoa(cli_addr.sin_addr)<<" :"<<ntohs(cli_addr.sin_port)<<endl;
while(1)
{
char buf[1024]={0};
ssize_t recv_size=recv(newsockfd,buf,sizeof(buf)-1,0);
if(recv_size<0)
{
perror("recv");
return 0;
}
else if(recv_size==0)
{
cout<<"close peer connect"<<endl;
close(newsockfd);
continue;
}
cout<<"buf:"<<buf<<endl;
memset(buf,'\0',sizeof(buf));
strcpy(buf,"i am serve!!!");
send(newsockfd,buf,strlen(buf),0);
sleep(1);
}
return 0;
}
客户端不是不允许调用 bind(), 只是没有必要调用 bind() 固定一个端口号 . 否则如果在同一台机器上启动多个客户端, 就会出现端口号被占用导致不能正确建立连接 ;服务器也不是必须调用 bind(), 但如果服务器不调用 bind(), 内核会自动给服务器分配监听端口 , 每次启动服务器时端口号都不一样, 客户端要连接服务器就会遇到麻烦 ;
测试多个连接的情况
再启动一个客户端 , 尝试连接服务器 , 发现第二个客户端 , 不能正确的和服务器进行通信 .分析原因 , 是因为我们 accecpt 了一个请求之后 , 就在一直 while 循环尝试 read, 没有继续调用到 accecpt, 导致不能接受新的请求.我们当前的这个 TCP, 只能处理一个连接 , 这是不科学的
对比多线程和多进程版本
多线程:缺点:鲁棒性不好,一个线程异常退出,进程退出。
优点:占用资源较少,效率较好
多进程:缺点:占用资源多,效率低,来一个客户端,创建一个进程。
有点:鲁棒性好,进程独立性。
但是当客户请求过多时,进程或者线程的调度就成了主要的矛盾。
我们可以使用线程池,线程池一定程度上确定了线程的数量,不会有大量的线程。在一段时间统一创建一批线程,不需要在用户请求时创建