第一章:网络基础
1. 协议
1.1 什么是协议?
从应用的角度出发,协议可以理解为“规则”,是数据传输和数据的解释规则。
假设,A与B双方欲传输文件,规定:
第一次,传输文件名,接收方接收到文件名,应答OK给传输方
第二次,发送文件的尺寸,接收方接收到该数据再次应答一个OK
第三次,传输文件内容。同样,接收方接收数据完成后应答OK表示文件内容接收成功。
这种仅在A、B之间被遵守的协议称之为原始协议、当次协议被更多的人采用,不断地增加、改进、维护和完善。最终形成一个稳定的、完整的文件传输协议,被广泛应用于各种文件传输过程中。该协议就成为一个标准协议。最早的ftp协议就是由此衍生而来。
1.2 典型协议
应用层:HTTP协议、FTP协议
传输层:TCP/UDP协议
网络层:IP协议、ICMP协议、IGMP协议
网络接口层:ARP协议、RARP协议
2. OSI7层模型和TCP/IP4层模型
TCP/IP常用协议:
- 应用层:http、ftp、nfs、ssh、telnet......
- 传输层:TCP、UCP
- 网络层:IP、ICMP、IGMP
- 网络接口(链路层):以太网帧协议、ARP协议
3. 网络通信过程
系统帮助我们把数据一步步封装,交给网卡,网卡接收到封装的数据,在网络上传输到目的主机的网卡,目的主机的网卡再一步步解封。
数据没有封装就不能在网络中传递!!!好比寄快递,不添加地址姓名电话等相关信息,接收人就无法接收到快递。
3.1 以太网帧(链路层)
以太网帧的格式:
由于本主机不知道目的主机的MAC地址,因此在给目的主机发送以太网帧之前,先通过ARP请求来获取目的主机的MAC地址。
路由器接收到ARP请求之后,经过查找和广播等多次寻找,最终发出ARP应答给本主机。
ARP协议:根据IP地址获取MAC地址(网卡唯一标识)
以太网帧协议:根据MAC地址,完成数据传输。
以太网帧的几种形式:
类型(2字节) | 数据 | |
以太网帧(MAC帧) | 0800 | IP数据报 |
0806 | ARP请求答应 | |
0835 | RAPR请求答应 |
3.2 IP协议(网络层)
版本:IPv4,IPv6
TTL:time to live。设置数据包在路由节点中的跳转上限。每经过一个路由节点,该值减1.
IP地址:可以在网络环境中,唯一标识一台主机
端口号:可以在网络的一台主机上,唯一标识一个进程
IP地址+端口号:可以在网络环境中,唯一标识一个进程。
3.3 TCP协议(传输层)
TCP协议的三次握手和四次挥手:
(1)三次握手
(2)数据通信
(3)四次挥手
3.4 C/S和B/S模型
Client-Server | Browse-Server | |
优点 | 缓存大量数据、协议选择灵活 速度快 | 安全性好、跨平台、开发工作量较小 |
缺点 | 安全性差、开发量大(客户端服务器都要开发) | 不能缓存大量数据、严格遵守http |
3.5 TCP状态转换图
3.6 netstat -apn
5836和6380表示进程PID
协议 | 接受队列长度 | 发送队列长度 | 本地IP地址和端口号 | 远程IP地址和端口号 | 连接的状态 | 进程PID/进程名称 |
TCP | 0 | 0 | 0.0.0.0:9527 | 0.0.0.0:* | LISTEN | 5868/./server |
TCP | 0 | 0 | 127.0.0.1:9527 | 127.0.0.1:45648 | ESTABLISHED | 6380/./server |
TCP | 0 | 0 | 127.0.0.1:45648 | 127.0.0.1:9527 | ESTABLISHED | 6379/./client |
3.7 TCP状态
注意:主动和被动不区分服务器还是客户端。谁先谁就主动。
建立连接大多数都是客户机发起,关闭的话,客户机和服务器都有可能。
如果先关闭服务器,再关闭客户端,再打开服务器,服务器打不开,因为此时服务器正处于2MSL状态。(因为服务器的套接字还没有被释放,没有变成close状态。)
!!!只有主动发起关闭的一方,才需要等待2MSL时长,才会出于close状态。
为什么要有2MSL时长?
因为发送端最后发送完ACK后,接收端如果接收不到ACK,接收端会一直发FIN发送端就继续发送ACK。若发送端等了2MSL之后,发送端没有发送FIN,我们可以认为接收端收到了最后一个ACK
3.8 端口复用函数
背景:客户端与服务器端建立连接之后,服务器端断开连接,立即重启服务器端,此时服务器的端口出于2MSL等待状态,没有进入close状态,因此此端口不能用。
#include <sys/types.h>
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen)
第二章:Socket编程
1. 字节序
对于整数0x12345678,我们将这个4字节整数从低地址往高地址存。
0x12是最高位,0x78是最低位。
大端存储最先存最高位。
小端存储最先存最地位。
TCP/IP协议规定:网络数据流应采用大端字节序。而大部分主机字节序时小端字节序
转换函数 | 作用 |
htonl | 二进制小端地址->二进制大端地址 |
htons | 二进制小端端口->二进制大端端口 |
ntohl | 二进制大端地址->二进制小端地址 |
ntohs | 二进制大端端口->二进制小端端口 |
inet_pton | 字符串小端->二进制大端 |
inet_ntop | 二进制大端->字符串小端 |
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
h表示host,n表示network,l表示long,s表示short。
IP地址4字节(32位),端口号2字节(16位),因此l给IP用,s给Port用。
注意:192.168.1.110是点分十进制IP,本质上是一个String,即“192.168.1.110”字符串。
但是上述函数的参数为无符号整形。
“192.168.1.110”通过atoi()函数转换为32位的int数据。
2. IP地址转换函数
2.1 本地字节序(string IP)----》网络字节序
#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
- af: AF_INET, AF_INET6
- src: 传入参数,IP地址(字符串)
- dst:传出参数,转换后的网络字节序的IP地址
“192.168.1.131”---------->网络字节序+的IP
即将本地的小端存储的字符串“192.168.1.131”转换为大端存储的32位二进制数据。
返回值:成功返回1,异常:0,说明src指向的不是一个有效的ip地址
2.2 网络字节序----》本地字节序(string IP)
const char *inet_ntop(int af, const void *src, char *dst, socklen_t len);
af: AF_INET, AF_INET6
src: 网络字节序IP地址
dst:本地字节序(string IP)
size:dst的大小
成功返回dst,失败返回NULL。
即将网络大端端存储的的32位二进制数据转换为本地字节序字符串“192.168.1.131”
3. sockaddr结构体和sockaddr_in的关系
我们在后面的socket编程函数要用到struct sockaddr类型,但是现在大家用的是struct sockaddr_in数据类型,现在通常的做法为,先用sockaddr_in来填充数据,最后再将sockaddr_in强制转换为sockaddr类型。
在网络编程中,我们重点关注sockaddr_in数据结构,最后使用套接字的时候,再将其强制转换为sockaddr结构。
例如:我们使用bind函数的时候,要用到sockaddr结构体
如何做?
1. struct sockaddr_in addr; // 先创建sockaddr_in的结构体
2. addr.sin_family = AF_INET/AF_INET6; // 选用IPv4或IPv6的协议
3. addr.sin_port = htons(9527); // 将主机小端二进制9527转换为网络大端二进制9527
4. addr.sin_addr.s_addr = htonl(INADDR_ANY);//取出系统中有效的 任意IP地址。二进制类型
5. bind(fd, (struct sockaddr *) &addr, size); // 强制转换sockaddr_in为sockaddr类型
注意:客户端可以不使用bind绑定套接字的IP和端口号,系统会自动隐式绑定。
4. 网络套接字函数
注意:客户端有一个套接字,服务器端有两个套接字(监听和数据传输)
4.1 socket()函数
作用:创建一个套接字
函数原型:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数:
- domain: AF_INET(遵守IPv4协议),AF_INET6(遵守IPv6协议), AF_UNIX(遵守本地协议)
- type: SOCK_STREAM(TCP连接),SOCK_DGRAM(UDP连接)
- protocol: 默认传0
返回值:
成功返回创建套接字的文件描述符,失败返回-1并设置errno
4.2 bind()函数
作用:给socket绑定一个地址结构(IP+port)
函数原型:
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
- sockfd:socket函数的返回值
- addr: 地址结构(sockaddr_in结构强转)
- addrlen:sizeof(addr)地址结构的大小
返回值:
成功返回0,失败返回-1并设置errno
4.3 listen()函数
函数原型:
#include <sys/type.h>
#include <sys/socket.h>
int listen(int sockfd, int backlog);
参数:
- sockfd: 套接字的文件描述符
- backlog: 服务器能够同时接收客户端的最大连接数,最大值为128。
返回值:
成功返回0,失败返回-1并设置errno
注意:listen()函数不是用来阻塞监听的!
4.4 accept()函数
阻塞等待客户端建立连接,成功的话,返回一个与客户端成功连接的socket文件描述符。
函数原型:
#include <sys/type.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
- sockfd:最开始创建的sockfd文件描述符
- addr: 传出参数,返回与服务器建立连接的客户端的地址结构
- addrlen:传入传出参数。传入:addr的大小;传出:客户端addr实际大小
返回值:
成功返回服务器 用于通信的文件描述符,失败返回-1并设置errno
listen()函数将套接字转为可接受连接状态。accept()函数受理连接请求。好比调用了listen()函数之后,别人给你打电话,你的手机会响铃。accpet()函数就是接电话,实现通信。
4.5 connect()函数
使用客户端的socket与服务器建立连接
函数原型:
#include <sys/type.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
- sockfd:客户端套接字的文件描述符
- addr:传入参数。服务器的地址结构
- addrlen:服务器地址结构的大小
返回值:
成功返回0,失败返回-1并设置errno
注意:客户端和服务器端成功建立连接之后,缓冲区的数据会被内核通过协议栈处理经过网卡和网络自动传输到对方缓冲区。具体来说:
- 客户端向发送缓冲区写入“hello”,则“hello”数据包会经过处理自动发送到服务器的接收缓冲区;
- 服务器向发送缓冲区写入“HELLO”,则“HELLO”数据包会经过处理自动发送到客户端的接收缓冲区;
5. C/S TCP模型
5.1 普通的CS模型
创建服务器流程分析:
- socket()创建服务器的监听套接字
- bind()将服务器给服务器的监听套接字绑定IP地址和Port端口号
- listen()设置服务器端能够连接客户端的最大连接数。默认128
- accept()阻塞等待客户端连接,连接成功之后,系统默认创建新的用于与客户端进行数据交换的套接字,accept()返回该数据通信套接字的文件描述符
- 与客户端进行数据交换
- close()关闭服务器端的俩个套接字
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <errno.h>
#include <ctype.h>
#include <arpa/inet.h>
#define SERV_PORT 9527
void sys_err(const char *str){
perror(str);
exit(1);
}
int main(int argc, char *argv[]){
int lfd = 0 ,cfd = 0;
int ret,i;
int buf[BUFSIZ]; //BUFSIZ==4096
struct sockaddr_in serv_addr;//初始化地址结构体,并给其分配IP和端口号
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);//IP地址自动获取
struct sockaddr_in clit_addr; //accept()的传出参数
socklen_t clit_addr_len; //accept()的传入传出参数
lfd = socket(AF_INET,SOCK_STREAM,0);//1.服务器端创建套接字
if (lfd == -1)
sys_err("socket error");
bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); //2.bind给服务器套接字分配地址
listen(lfd,128); //3. 设置服务器端最大连接客户端请求上限
clit_addr_len = sizeof(clit_addr);
//accept()调用成功的话, clit_addr会保存客户端的地址结构, clit_addr_len保存长度
cfd = accept(lfd,(struct sockaddr *)&clit_addr, &clit_addr_len);
if (cfd == -1)
sys_err("accept error");
//服务器与客户端建立连接成功----数据通信
while(1){
ret = read(cfd,buf,sizeof(buf));
write(STDOUT_FILENO,buf,ret);
for (i = 0; i < ret; i++)
buf[i] = toupper(buf[i]);
write(cfd,buf,ret);
}
close(lfd);
close(cfd);
return 0;
}
创建客户端流程分析:
- socket()创建套接字
- 【bind()给套接字分配IP地址】此步骤可有可无,不调用bind的话系统自动分配
- connect()与请求连接服务器
- 与服务器进行数据通信
- close()
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#define SERV_PORT 9527
void sys_err(const char *str){
perror(str);
exit(1);
}
int main(int argc, char *argv[]){
int cfd;
struct sockaddr_in serv_addr; // 服务器的地址结构
serv_addr.sin_family = AF_INET;
serv_addr.sin_port =htons(SERV_PORT);//存储网络字节序格式的端口号
inet_pton(AF_INET,"127.0.0.1",&serv_addr.sin_addr.s_addr);//存储网络字节序的IP地址
cfd = socket(AF_INET,SOCK_STREAM,0);//1.创建socket套接字
if (cfd == -1)
sys_err("socket error");
int ret = connect(cfd,(struct sockaddr *)&serv_addr, sizeof(serv_addr));//2.与服务器套接字建立连接
if (ret != 0)
sys_err("connect err");
int counter = 10;
char buf[BUFSIZ];
while(--counter){//通信
write(cfd,"hello",5);//写“hello”到缓冲区
ret = read(cfd,buf,sizeof(buf));//读缓冲区内容到buf中,注意,read是阻塞读取的
write(STDOUT_FILENO,buf,ret);
}
close(cfd);
}
数据通信:
- 运行./server
- 运行./client, 成功完成数据通信
- 再次运行client,此时无法通信
此模型缺点:运行server后,accpet只调用了一次,因此当一个client发出连接请求,accpet便不再监听,而是与客户端建立连接,开始通信,无法做到一个server程序同时与多个client通信。
改进为多进程服务器,见第三章3.1小节。
6. C/S TCP封装模型
第三章:高并发服务器
3.1 多进程并发服务器
关键点:父进程负责fork()子进程的同时,套接字的引用也会增加。
- 父进程只用负责监听套接字,关闭通信套接字
- 子进程只负责通信,要关闭监听套接字引用
- 父进程借助信号机制通过waitpid(0,NULL,WNOHANG)回收结束的子进程。(防止子进程变成僵尸进程)
核心:
- 父进程负责监听客户端的请求,通过信号回收子进程
- 子进程负责与多个客户端的通信
- fork函数会导致子进程复制父进程所有资源,因此调用fork()函数后,监听套接字的引用等于2,通信套接字的引用等于2
#include <stdio.h>
#include <ctype.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <sys/wait.h>
#define SERVE_PORT 9527
//捕捉信号的回调函数
void catch_child(int sigsum)
{
while((waitpid(0,NULL,WNOHANG))>0);
return ;
}
int main()
{
int lfd,cfd;
pid_t pid;
char buf[BUFSIZ];
int ret, i;
//地址结构体
struct sockaddr_in serve_addr, client_addr;
socklen_t client_addr_len;
bzero(&serve_addr,sizeof(serve_addr)); //将地址结构清零
serve_addr.sin_family = AF_INET;
serve_addr.sin_port = htons(SERVE_PORT);
serve_addr.sin_addr.s_addr = htonl(INADDR_ANY);
//1. 创建socket
lfd = socket(AF_INET,SOCK_STREAM,0);
if(lfd == -1)
{
perror("socket error");
exit(1);
}
//2. 绑定ip和端口
bind(lfd,(struct sockaddr *)&serve_addr,sizeof(serve_addr));
//3. 设置上限
listen(lfd,128);
//4. 子进程用于通信,父进程监听并回收子进程
client_addr_len = sizeof(client_addr);
while(1)
{
//这里的accept会阻塞等待
cfd = accept(lfd,(struct sockaddr *)&client_addr,&client_addr_len);
pid = fork();
if(pid<0)
{
perror("fork error");
exit(1);
}
else if(pid==0) //子进程
{
close(lfd); //关闭用于监听的套接字
//进程的地址空间是独立的,这是关闭的是进程中的lfd,父进程的还是正常监听中
for(;;){
ret = read(cfd,buf,sizeof(buf)); //读数据
//如果ret=0就说明客户端那边关闭了连接,我们就关闭cfd 退出进程
if(ret == 0)
{
close(cfd);
exit(1);
}
for(i=0;i<ret;i++)
buf[i] = toupper(buf[i]); //改数据
write(cfd,buf,ret); //写回数据
write(STDOUT_FILENO,buf,ret);
}
}
else //父进程
{
//信号回收子进程
struct sigaction act;
act.sa_handler = catch_child;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD,&act,NULL);
close(cfd); //关闭用于通信的套接字
continue;
}
}
return 0;
}
缺点:多个进程对应处理多个客户端请求,系统开销大。
3.2 多线程并发服务器
- socket()
- bind()
- listen()
- while(1){
cfd = Accept(lfd,)
pthread_create(&tid,NULL,tfn,NULL);
} - 子线程:
3.3 多路I/O转接(复用)服务器
IO多路转接也称为IO多路复用,它是一种网络通信的机制。通过这种方式可以同时监测多个文件描述符并且这个过程是阻塞的,一旦检测到有文件描述符就绪(可以读数据/可以写数据)程序的阻塞就会被解除,之后就可以基于这些(一个或多个)就绪的文件描述符进行通信了。通过这种方式在单线程/进程的场景下也可以在服务器端实现并发。常见的IO多路转接方式有:select、poll、epoll。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
3.3.1 select()
背景:服务器端里的建立连接的accept()和数据通信的write()和read()会导致服务器端阻塞。
原理:借助内核,select来监听,客户端连接、通信事件。