TCP/IP网络编程
看Linux的高性能服务器看到第二部分之后,发现很多都是实现细节,看不下去了,只能回来看这本书—尹圣雨
第一部分 开始网络编程
第一章 理解网络编程和套接字
- 操作系统会提供「套接字」(socket)的部件,套接字是网络数据传输用的软件设备。因此,「网络编程」也叫「套接字编程」。「套接字」就是用来连接网络的工具。
- 网络编程中接受连接请求的套接字创建过程:
- 调用socket函数创建套接字
- 调用bind函数分配IP地址和端口号
- 调用listen函数转为可接受请求状态
- 调用accept函数受理连接请求
- 文件描述符是系统分配给文件或套接字的整数。在windows中文件描述符也被称为文件句柄。创建文件描述符的时候是从3开始的由小到大的顺序编号,因为0,1,2分配给了标准IO描述符。
- ssize_t, size_t等以_t结尾的数据类型都是元数据类型,在sys/types.h头文件中一般由typedef来进行定义,当有变动的时候会大大减少代码的改动量。
第二章 套接字类型 与协议设置
套接字函数
int socket(int domain, int type, int protocal)
第一个参数是domain是协议族
名称 协议族 PF_INET IPV4互联网协议族 PF_INET6 IPV6互联网协议族 PF_LOCAL 本地通信的UNIX协议族 PF_PACKET 底层套接字的协议族 PF_IPX IPX Novell协议族 第二个参数是套接字类型
- 面向连接的套接字(SOCK_STREAM),可靠的,按序传递,基于字节的面向连接的数据传输方式的套接字
- 传输过程中数据不会消失
- 按序传输数据
- 传输的数据不存在数据边界
- 面向消息的套接字(SOCK_DGRAM),不可靠,不按序传递,以数据的高速传输为目的的套接字
- 强调快速传输而非传输顺序
- 传输的数据可能丢失,也可能损坏
- 传输的数据由数据边界
- 限制每次传输的数据的大小
- 面向连接的套接字(SOCK_STREAM),可靠的,按序传递,基于字节的面向连接的数据传输方式的套接字
第三个参数 协议选择, 一般情况下通过前面两个参数可以确定第三个参数,第三个参数可以设置为0。当同一个协议族中存在多个数据传输方式相同的协议。
第三章 地址族与数据序列
计算机中一般会配有NIC(Network Interface Card,网络接口卡)数据传输设备。通过网卡接收的数据内是由端口号的。
TCP套接字的端口号可以和UDP套接字的端口号相同。
POSIX(Portable Operating System Interface, 可移植操作系统接口)。POSIX是为UNIX系列操作系统设立的标准。
字节序列转换的时候
1 | unsigned short htons(unsigned short); |
- 每一次创建服务器套接字都要输入IP地址很麻烦,可以将IP地址换为INADDR_ANY,这种方法可以自动获取服务器端的计算机的IP地址。
- 初始化服务器端的套接字时应该分配所属计算机的IP地址,因为同一个计算机中可以分配多个IP地址。一般一个网卡只能有一个IP地址。
第四章 基于TCP服务端/客户端(1)
标准本身就在于对外公开,引导更多的人遵守规范。以多个标准为依据设计的系统称为开放式系统。
TCP服务端的默认函数调用顺序为
- 创建套接字,socket()
- 分配套接字地址,bind()
- 等待连接请求状态,listen()
- 允许连接,accept()
- 数据交换,read()/write()
- 断开连接,close()
服务端处于listen()等待连接状态是指,客户端请求连接时,服务器在受理连接之前一直使请求处于等待状态。
服务端处于accept()可接受数据状态时,将自动创建套接字,并且连接到发起请求的客户端。
TCP客户端的默认函数的调用顺序
- 创建套接字,socket()
- 请求连接,connect()
- 数据交换,read()/write()
- 断开连接,close()
客户端调用connect函数之后,发生一下情况才会返回
- 服务器端接受连接请求,不是指服务器端调用accept函数,而是把链接请求信息记录到等待队列,所以connect函数返回后并不立即进行数据的交换
- 发生断网等异常情况而中断连接请求
客户端实现的过程中,在调用connect函数时,在操作系统中,准确的是在内核中,Ip使用主机IP,随机端口,来给套接字分配IP和端口,不是像服务器端那样使用bind进行分配、
迭代服务器端,最简单的实现办法就是插入循环语句反复调用accept函数。
回声服务器/客户端,服务器端将客户端传输的字符串原封不动的传回客户端,就像回声一样。
服务器端在同一时刻只与一个客户端相连,并提供回声服务。
服务器端依次向 5 个客户端提供服务并退出。
客户端接受用户输入的字符串并发送到服务器端。
服务器端将接受的字符串数据传回客户端,即「回声」
服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。
第五章 基于TCP的服务端/客户端(2)
- 写循环语句的时候应该尽量降低因为异常情况而陷入无限循环的可能。
- TCP套接字中的IO缓冲,也就是write函数和read函数调用的时候,会将数据移动到缓冲区,有以下特点
- IO缓冲在每一个TCP套接字中单独存在
- IO缓冲在创建套接字的时候自动生成
- 即使关闭套接字也会继续传递输出缓冲中遗留的数据
- 关闭套接字将丢失输入缓冲中遗留的数据
- write函数在数据移处输出缓冲区的时候就会返回,然后TCP会保证对输出缓冲数据的传输,因此,可以说write函数是在数据完成传输的时候返回。
- TCP套接字从创建到消失可以分为以下3步
- 与对方套接字建立连接,也就是三次握手的过程,seq–表示我的序号,ack–应答序号,我想让你给我传
- [SYN] seq = 1000, ack = ,我的序号是1000,我想要你给我传1001
- [SYN+ACK] seq = 2000,ack = 1001,我的序号是2000,我想要你给我传2001,我给你传1001
- [ACK] seq = 1001,ack = 2001,我的序号是1001,我给你传2001
- 与对方套接字进行数据交换
- ack = seq + 传递的字节数 + 1
- 断开与对方套接字的连接,也就是四次挥手的过程
- 注意第三次挥手的ack和第二次挥手的ack是一个数,因为第二次挥手之后,没有接受数据
- 与对方套接字建立连接,也就是三次握手的过程,seq–表示我的序号,ack–应答序号,我想让你给我传
第六章 基于UDP的服务器端/客户端
- UDP的作用是提供可靠的数据传输服务,TCP在不可靠的IP层进行流控制,UDP没有这种流控制机制。所以流控制是区分TCP和UDP的重要标志。
- UDP的作用就是根据端口号将传到主机的数据包交付给最终的UDP套接字。
- TCP比UDP慢的原因:
- 收发数据前后进行连接设置以及清楚过程
- 收发数据过程中为了保证可靠性而添加的流控制
- TCP中套接字是一对一的关系,但是在UDP中不管是服务端还是客户端都只需要一个套接字。
- UDP客户端在调用sendto函数的时候,发现没有分配IP地址和端口的时候会自动分配。当然bind()函数是不区分TCP服务还是UDP服务的,所以也可以使用bind()函数进行绑定。
- TCP数据传输不存在边界,表示的是在数据传输过程中调用IO函数的次数不具有任何意义。而UDP是由数据边界的协议,输入函数调用IO的次数应该与输出函数的相同。
- sendto()函数的传输过程,下面第一个和第三个过程占整个通信过程的近1/3时间
- 向UDP套接字注册目标IP和端口号
- 传输数据
- 删除UDP套接字中注册的目标地址信息
- 为注册目标地址信息的套接字称为未连接套接字,注册了目标地址套接字称为连接connected套接字。当连续为同一个IP,同个端口号传递多个数据的时候,已经连接的UDP套接字变成未连接的UDP套接字会大大提高通信效率。
- 创建已连接UDP套接字的方式—-只需针对UDP套接字调用connect函数。连接了之后除了可以使用sendto和recvfrom以外还可以使用write,read等函数。
第七章 优雅的断开套接字连接
Linux的close和Windows的closesocket函数将同时断开两个流。这可能导致后面接受到的数据也进行了销毁,十分的不优雅。
使用的shutdown函数就是断开其中的一半流
1
int shutdown(int sock, int howto);
- SHUT_RD: 断开输入流
- SHUT_WR: 断开输出流
- SHUT_RDWR: 同时断开IO流
想要断开连接的主机,在向对方发送完EOF之后,就可以进入半关闭状态。否则可能导致程序得堵塞。
第八章 域名及网络地址
可以使用一下两个函数通过域名获取IP,或者反过来
1
2struct hostent* gethostbyname(const char* hostname);
struct hostent* gethostbyaddr(const char* addr, socklen_t len, int family);
第九章 套接字的多种可选项
套接字是有一些选项可以选择的
协议层 选项名 读取 设置 SOL_SOCKET SO_SNDBUF O O SOL_SOCKET SO_RCVBUF O O SOL_SOCKET SO_REUSEADDR O O SOL_SOCKET SO_KEEPALIVE O O SOL_SOCKET SO_BROADCAST O O SOL_SOCKET SO_DONTROUTE O O SOL_SOCKET SO_OOBINLINE O O SOL_SOCKET SO_ERROR O X SOL_SOCKET SO_TYPE O X IPPROTO_IP IP_TOS O O IPPROTO_IP IP_TTL O O IPPROTO_IP IP_MULTICAST_TTL O O IPPROTO_IP IP_MULTICAST_LOOP O O IPPROTO_IP IP_MULTICAST_IF O O IPPROTO_TCP TCP_KEEPALIVE O O IPPROTO_TCP TCP_NODELAY O O IPPROTO_TCP TCP_MAXSEG O O 从表中可以看出,套接字可选项是分层的。
IPPROTO_IP 可选项是IP协议相关事项
IPPROTO_TCP 层可选项是 TCP 协议的相关事项
SOL_SOCKET 层是套接字的通用可选项。
wait-time状态指在关闭连接后保持一段时间的状态。这个状态存在的主要目的是确保网络中的所有数据包都被正确地接收和处理,以防止在连接关闭后出现冗余的数据包。时间一般为两个MSL,报文最长生存时间。
任何一个发送完FIN的主机,包含客户端和服务端,都有wait-time时间。
Nagle算法,1984年诞生,只有收到前一数据的ack消息时,Nagle算法才发送下一数据。注意下一个数据不见得只有一个字符。
Nagle算法的当发送数据时,先将数据放入缓冲区中,并设置一个定时器。在定时器到期之前,如果有新的数据需要发送,则继续将数据放入缓冲区;一旦定时器到期,则将缓冲区中的数据一次性发送出去。
一般情况下,网络流量未受到太大影响时,比如传输大文件的时候时,不使用Nagle算法。也就是说缓冲区有数据就立即发送,这会加快文件的传输数据。
第十章 多进程服务器端
- 具有代表性的并发服务器端实现模型和方法
- 多进程服务器:通过创建多个进程提供服务
- 多路复用服务器:通过捆绑并同一管理IO对象提供服务
- 多线程服务器:通过生成与客户端等量的线程提供服务
- 僵尸进程(Zombie Process)是指一个已经终止执行的子进程,但是其父进程尚未调用
wait()
或waitpid()
函数来获取子进程的终止状态,从而导致子进程的进程控制块(Process Control Block,PCB)仍然保留在系统中,占用了系统资源,但不再执行任何代码。 - 在使用
fork()
函数创建子进程时,父进程会调用fork()
函数,并在调用后得到返回值。这个返回值在父进程中是子进程的 PID,而在子进程中是0。这样,父进程和子进程可以根据返回值来判断自己是父进程还是子进程。也就是在处理if(pid == 0)
应该是用来处理子进程。 - 运行程序得时候
./out &
表示这个执行程序将在后台进行 - 调用
wait()
函数的时候,如果没有已经终止的子进程,程序将阻塞,直到有进程终止,因此应该谨慎使用该函数。调用waitpid()
就不会阻塞 - 父进程和子进程都很忙,父进程即使调用了
waitpid()
之后,也不会一直等待子进程结束。子进程终止的识别主体是操作系统,操作系统可以通过信号量的方式来通知父进程子进程已经结束了。 - signal函数在UNIX系列的不同的操作系统中可能存在区别,但是sigaction函数完全相同。而且sigaction可以完全替代signal
- 子进程终止的时候将产生SIGCHLD信号。
- 创建子进程的时候,fork函数会复制父进程的所有资源,他们的套接字的文件描述符会指向同一个套接字。严格意义上讲,套接字是属于操作系统的,进程拥有的是指向套接字的文件描述符。
- 客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。这样会更加简单。
第十一章 进程间通信
- 为了完成进程间的通信IPC(Inter-Process Communication,进程间通信),操作系统应该提供两个进程可以同时访问的内存空间。
- 管道并非属于进程的资源,而是属于操作系统,也就是fork函数不会进行复制。
- 我们如何将创建的管道的文件描述符传递给子进程呢,通过的就是fork函数。
- 一般来说只用一个管道来进行双向信息传递可能会导致错误,所以需要使用双向管道。
第十二章 IO复用
运用select函数是最具有代表性的实现复用服务器端的方法。使用select函数时可以监控下面这些事件
- 是否存在套接字接受数据?
- 无需阻塞传输数据的套接字有哪些?
- 哪些套接字发生了异常?
select函数很难用,但是它是“IO复用的全部内容”
- 设置文件描述符、设置监视范围、设置超时
- 调用select函数
- 查看调用结果
设置文件描述符,先进行集中,按接收、传输、异常来进行分类
FD_ZERO(fd_set *fdset)
:将 fd_set 变量所指的位全部初始化成0FD_SET(int fd,fd_set *fdset)
:在参数 fdset 指向的变量中注册文件描述符 fd 的信息FD_SLR(int fd,fd_set *fdset)
:从参数 fdset 指向的变量中清除文件描述符 fd 的信息FD_ISSET(int fd,fd_set *fdset)
:若参数 fdset 指向变量中包含文件描述符 fd 的信息,则返回真
第十三章 多种IO函数
以下两个函数可以用来完成IO操作
1
2
3
4
5
6
7
8
9
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
/*
成功时返回发送的字节数,失败时返回 -1
sockfd: 表示与数据传输对象的连接的套接字和文件描述符
buf: 保存带传输数据的缓冲地址值
nbytes: 待传输字节数
flags: 传输数据时指定的可选项信息
*/1
2
3
4
5
6
7
8
9
10\
ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
/*
成功时返回接收的字节数(收到 EOF 返回 0),失败时返回 -1
sockfd: 表示数据接受对象的连接的套接字文件描述符
buf: 保存接受数据的缓冲地址值
nbytes: 可接收的最大字节数
flags: 接收数据时指定的可选项参数
*/send & recv 函数的可选项意义:
可选项(Option) 含义 send recv MSG_OOB 用于传输带外数据(Out-of-band data) O O MSG_PEEK 验证输入缓冲中是否存在接受的数据 X O MSG_DONTROUTE 数据传输过程中不参照本地路由(Routing)表,在本地(Local)网络中寻找目的地 O X MSG_DONTWAIT 调用 I/O 函数时不阻塞,用于使用非阻塞(Non-blocking)I/O O O MSG_WAITALL 防止函数返回,直到接收到全部请求的字节数 X O MSG_OOB可以用来发送带外数据,也就是紧急程度较高的数据。这里的OOB含义是通过完全不同的通信路径传输的数据。
MSG_OOB的真正意义在于督促数据接收对象尽快处理数据,而且TCP“保持传输顺序”的特性依然成立。
使用readv和writev可以对数据进行整合传输及发送。用于进行读取和写入多个非连续缓冲区数据的函数,它们通常用于提高 I/O 操作的效率,减少系统调用的次数。
第十四章 广播与多播
- 多播(Multicast)方式的数据传输是基于UDP完成的,发送数据到所有加入了多播组的主机。
- 数据传输的特点如下:
- 多播服务器端针对特定多播组,只发送一次数据
- 即使只发送一次数据,但该组内的所有客户都会接收数据
- 多播组数可在IP地址范围内任意的增加。
- 加入特定组即可接受发往该多播组的数据。
- 设置TTL和加入多播组都是通过设置套接字可选项
setsockopt();
进行的。 - 多播中的发送者为sender,接受者为recever来代替服务器端和客户端。
- 多播是基于MBone这个虚拟网络工作的。即通过网络中的特殊协议的软件概念上的网络。
- 广播(broadcast)发送数据到局域网内的所有主机。一般分为两种
- 直接广播 ip 网络名不变,主机名全是1
- 本地广播 ip为255.255.255.255
第二部分 基于Linux的编程
第十五章 套接字与标准IO
之前编写的程序都是系统IO,比如使用套接字,如果要是可以使用标准IO,也就是C语言库函数的那些内容,将会额外的提供一层缓冲,效果也就更好。
标准IO的优点
- 具有良好的移植性
- 利用缓冲提高性能
一般情况下,有缓冲时,传输数据所携带的额外信息占比越少,传输的时间越少,同时,移动数据的次数越少,性能提升也就越大。
例如,read, write, open, close就是系统IO,printf, scanf, fopen, fclose, fread, fwrite, fputs就是c标准库提供的标准IO。
标准IO的缺点
- 不容易进行双向通信
- 有时可能频繁的调用fflush函数
- 需要以FILE结构体指针的形式返回文件描述符
创建套接字时返回文件描述符,而为了使用标准IO函数,只能将其转换为FILE结构体指针
- 利用fdopen将文件描述符转换为FILE结构体指针
1
2
3
4
5
6
7
8
9
10
11
12
13
FILE *fdopen(int fildes, const char *mode);
/*
成功时返回转换的 FILE 结构体指针,失败时返回 NULL
fildes : 需要转换的文件描述符
mode : 将要创建的 FILE 结构体指针的模式信息
*/- 利用fileno将FILE结构体指针,转换为文件描述符
int fileno(FILE *stream);
第十六章 关于IO流分离的其他内容
调用fopen函数打开文件之后就可以与文件交换数据,因此说调用fopen函数后创建了“流”。
第十章分离流带来的好处
- 通过分开输入过程(代码)和输出过程降低实现难度
- 与输入无关的输出操作可以提高速度
第十五章分离流带来的好处
- 为了将 FILE 指针按读模式和写模式加以区分
- 可以通过区分读写模式降低实现难度
- 通过区分 I/O 缓冲提高缓冲性能
- 销毁所有文件描述符后才能销毁套接字
- dup和dup2都是用来复制文件描述符的系统调用,它们可以创建一个新的文件描述符,该文件描述符与已有的文件描述符指向同一个文件表项。
- 无论复制出多少文件描述符,均应调用shutdown函数发送EOF并进入半关闭状态。
第十七章 优于select的epoll
select是传统的IO复用函数,在UNIX中广泛应用。epoll是Linux中更高效的方法。
select的IO复用速度慢的原因
- 调用select函数后常见的针对所有文件描述符的循环语句
- 每次调用select函数时都需要向该函数传递监视对象的信息—-这是最大的瓶颈
上面的缺点可以通过,仅向操作系统传递一次监视对象,监视范围或内容发生变化的时候只通知变化的事项。这就是Linux中epoll,Linux从2.3.44版本内核开始引入。
select具有很强的移植性,而epoll只在linux系统下面才成立。
epoll服务器端需要的3个函数
- epoll_create:创建保存 epoll 文件描述符的空间
- epoll_ctl:向空间注册并注销文件描述符
- epoll_wait:与 select 函数类似,等待文件描述符发生变化
在Linux2.6.8之后的内核完全忽略传入的epoll_create函数的size参数,而是会根据情况调整epoll例程的大小。
条件触发与边缘触发
- 条件触发,只要输入缓冲有数据就会一直通知该事件
- 边缘触发,输入缓冲收到数据的时候仅注册一次该事件,可以分离接收数据和处理数据的时间点
select函数是以条件触发的方式进行工作的。
第十八章 多线程服务器端的实现
- 可移植操作系统接口(英语:Portable Operating System Interface,缩写为POSIX)
- 根据线程中的临界区是否会引发问题,函数分为线程安全函数和非线程安全函数。
- 我们不用自己去区分线程安全函数和线程非安全函数,平台会自己进行定义。一般函数的后面会带有_r,一般可以更简答的在编译的时候加上-D_REENTRANT选项
- 临界区:函数内同时运行多个线程时引起的问题的多条语句构成的代码块。
- 应该尽量减少互斥量调用的次数,这会大大的减少程序运行的时间。
- 总的来说,互斥量更适用于对于临界资源的互斥访问,而信号量更适用于控制资源的访问数量。
- Linux的线程的销毁不是自动进行的,而是需要手动进行,一般使用以下两种方法
- pthread_join函数,线程终止之前,调用该函数的线程会一直阻塞,所以用到少
- pthread_detach函数
第四部分 结束网络编程
第二十四章 制作HTTP服务器端
- HTTP又可以称为无状态的Stateless协议,当然可以通过cookie和session技术来进行弥补。在实际应用中,通常会将 Cookie 和 Session 结合使用,将 Session ID 存储在 Cookie 中,从而实现对用户状态的跟踪和管理。
- cookie是保留在用户的浏览器中的,持久化的少量数据
- session是保留在服务器端的,更安全一些,一般是大量数据,比如购物车内容等。
- 客户端向服务器端发送的请求消息的结构为
- 请求行,分为get和post
- 请求头
- 请求体
- 服务器端向客户端发送的相应结构为
- 状态行
- 消息头
- 消息体
第二十五章 进阶内容
- 《UNIX环境高级编程》
- 《TCP/IP协议族》与《TCP/IP详解》卷1