TCP/IP网络编程

理解网络编程和套接字

我们使用套接字完成网络的数据传输,为什么要用“套接字”这个词呢?我们把插头插在插座上就能获取电力供给,同样,使用套接字就可以连接到网络。

构建接电话套接字

我们可以用TCP套接字比喻成电话机。

  1. socket函数 <->准备电话机(不同的在于电话机购买好后,安装和电话号码的分配交给电信局的人完成,而套接字需要我们自己安装和分配地址等)

  2. bind函数 <-> 分配电话号码(套接字是分配ip地址和端口号)

  3. listen函数 <-> 连接电话线(是套接字转为可接受连接的状态)

  4. accept函数 <->拿起话筒进行交流(套接字接受了对方的连接请求,开始通信)

构建打电话套接字

上述是服务端的套接字,下面介绍用于请求连接的客户端套接字。

第一步一致,然后就只需要直接向服务端发送连接请求即可(地址调用connect函数时会分配)。

connect函数 <-> 打电话(请求连接)

套接字与文件

在linux的世界里,socket被认为是文件的一种,因此在网络传输过程中也可以使用文件I/O的相关函数。

文件描述符是一个整数,只不过是为了方便称呼操作系统中的各个文件而赋予的数而已。

套接字类型与协议设置

创建套接字

socket函数创建套接字,返回套接字的文件描述符。参数为:

  • domain:协议族
  • type:传输方式
  • protocol:协议

协议族(Protocol Family)

套接字通信中协议具有一些分类,以下是常用的两个分类(其余此处忽略):

  • PF_INET:IPv4协议族
  • PF_INET6:IPv6协议族

套接字类型

即套接字的数据传输方式,作为socket函数的第二个参数传递。已通过第一个参数传输了协议族,还需要决定传输方式?问题就在于一个协议族中也存在多种数据传输方式。

以下有两种具有代表性的传输方式:

  • SOCK_STREAM:面向连接(基于流)的套接字(一般为TCP),可靠的、按序传递的、基于字节的。
  • SOCK_DGRAM:面向消息的套接字(一般为UDP),不可靠的、不按序传递的、以数据的高速传输为目的的。

协议的最终选择

一般而言传输前两个参数即可创建所需套接字,因此大部分情况下向第三个参数传递0即可。

除非遇到同一个协议族中存在多个数据传输方式相同的协议,这时我们就需要指定,如:

  • IPPROTO_TCP
  • IPPROTO_UDP

地址族与数据序列

分配给套接字的IP地址和端口号

IP地址是为收发网络数据而分配给计算机的值,而端口号是为了区分程序中创建的套接字而分配给套接字的序号。

地址信息的表示

表示IPv4地址的结构体

sockaddr_in结构体定义的协议族、ip地址和端口号,可作为地址信息传输给bind函数。

struct sockaddr_in {
    sa_family_t    sin_family;  // 地址族
    uint16_t       sin_port;    // 16位端口号
    struct in_addr sin_addr;    // 32位ip地址
    char           sin_zero[8]; // 不使用
}

而其中的in_addr结构体用于存放ip地址:

struct in_addr {
    in_addr_t     s_addr;      // 32位ipv4地址
}

sockaddr_in中的sin_zero成员是该结构体用于和sockaddr结构体大小保持一致而插入的,必须填充为0,否则无法使用。

为什么需要与sockaddr一致呢?而sockaddr又是什么?实际上,bind函数的第二个参数期望的就是sockaddr结构体变量的地址,其结构如下:

struct sockaddr {
    sa_family_t sin_family;   // 地址族
    char        sa_data[14];  // 地址信息
}

可以发现,sockaddr结构体将ip地址和端口号等信息都存在sa_data数组中,不方便程序员操作,因此就有了sockaddr_in结构体,方便操作,按之前说的方法填充sockaddr_in则可以生成符合bind函数要求的字节流。

sockaddr_in的成员分析

  • sin_family:每种协议族适用的地址族不同,常见的两种为AF_INET(IPv4的地址族)和AF_INET6(IPv6的地址族)
  • sin_port:保存16位端口号,注意:它以网络字节序保存
  • sin_addr:保存32位ip地址,也以网络字节序保存

网络字节序与地址变换

字节序与网络字节序

CPU向内存保存数据的方式有两种:

  • 大端序:高位字节存放在低位地址
  • 小端序:高位字节存放在高位地址

不同的CPU保存方式不同(虽然主流的intel系列CPU使用小端序保存),因此不同计算机间数据传输时要有统一的标准,即网络字节序:大端序。

字节序转换

unsigned short htons(unsigned short);   
unsigned short ntohs(unsigned short);
unsigned long htonl(unsigned long);
unsigned long ntohl(unsigned long);

h -> host,to -> 转换, n -> network,s -> short, l -> long。

short用于端口号的转换,long用于ip地址转换。

除了向sockaddr_in结构体填充数据时需要注意,其他情况无需考虑字节序的问题

网络地址的初始化与分配

字节序 - 字符串转整型

inet_addr函数完成这一转换,接受点分十进制的字符串,返回整型数据,注意:该整型数据就是满足网络字节序的

inet_aton与inet_addr在功能上完全相同,只不过该函数利用了in_addr结构体,更常用:

struct sockaddr_in addr_inet;
inet_aton(addr, &addr_inet.sin_addr); // 若成功(返回1,失败返回0),结果写入addr_inet.sin_addr.s_addr
...

ienet_ntoa与inet_aton功能相反。

n -> num, a -> address。

网络地址初始化

常见的初始化方法如下:

struct sockaddr_in addr;
char *serv_ip = "211.217.168.13";
char *serv_port = "9190";
memset(&addr, 0, sizeof(addr)); // 主要是为了将sin_zero清零
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(serv_ip);
addr.sin_port = htons(atoi(serv_port));

每次初始化都需要输入ip地址会比较繁琐,因此我们可以使用INADDR_ANY来初始化ip地址:

addr.sin_addr.s_addr = htonl(INADDR_ANY);

若采用这种方法,则可以自动获取服务端的ip地址,不必亲自输入,且如果计算机有多个ip地址(即有多个网卡的计算机),那么只要端口号一致,就可以从不同的ip地址接收数据。

基于TCP的服务端/客户端

函数调用顺序

服务端:socket -> bind -> listen -> accept -> read/write -> close

客户端:socket-> connect -> read/write -> close

调用bind函数给套接字分配地址后,就需要通过listen函数进入等待连接请求状态。只有调用了listen函数,客户端才可以进入可发出连接请求的状态,即这时候才能调用connect函数,之前调用会发生错误。

另外,connect函数成功了并不是已经建立连接开始通信了,只是进入了服务端的等待队列(listen函数的第二个参数表明了这一队列的长度),accept函数被调用后才是进行通信。

accept函数会自动创建一个新的套接字用于连接发起请求的客户端进行通信。为什么不用服务端的套接字呢?因为客户端的连接请求也属于从网络接收到的数据,而要接受数据就需要套接字,服务端的套接字就是用来接受请求的。

客户端套接字地址在哪?

connect函数参数只有套接字的文件描述符和目标地址,并未出现套接字地址的分配,不需要分配ip和端口吗?不是的,在调用connect函数时,内核会自动分配ip和端口,ip即主机的ip,端口随机。

粘包/拆包

注意:编写代码时需要注意TCP的粘包、拆包问题(要意识到TCP是面向字节流的协议,无消息边界)

write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE - 1);
message[str_len] = 0;
printf("message: %s", message);

以上代码就是错误假设了:“每次调用read、write函数时都会以传入的字符串为单位执行实际的I/O操作”。

实际上,有可能多次write函数传递的字符串一次性传到服务端,也有可能字符串太长,它拆分成了两个数据包进行传送。read也是一样,我们并不一定能一次读出期望长度的字符串。

为了解决这一问题,就需要应用层定义消息边界,可以是定长的(读取一定长度的字符串才结束),也可以是不定长的(定义结束符或加入表示长度的字段)。

TCP套接字的I/O缓冲

如前所述,TCP套接字数据收发没有边界,服务端调用1次write函数传输40字节数据,客户端也有可能通过4次read函数调用每次读取10字节。那么当第一次客户端接受10字节后,剩余的30字节存放在哪里呢?

实际上,内核为TCP套接字维护输入缓冲区与输出缓冲区,write函数调用只是将数据从进程的缓冲区移到输出缓冲区,在适当的时候传向对方的输入缓冲区;而read函数调用只是从输入缓冲区移动到进程的缓冲区。

I/O缓冲特性如下:

  • I/O缓冲在每个TCP套接字中单独存在
  • I/O缓冲在创建套接字时自动生成
  • 即使关闭套接字也会继续传递输出缓冲中遗留的数据
  • 但关闭套接字会丢失输入缓冲的数据

那么如果“客户端输入缓冲只有50字节,但服务端传输了100字节”呢?会丢失数据吗?

不会,因为TCP有流量控制。

基于UDP的服务端/客户端

UDP可靠性比不上TCP,但是也不会像想象中的那么频繁的丢失数据,因此在更重视性能而非可靠性的情况下,UDP是一个很好的选择。比如要通过网络实时传输视频时,对于多媒体数据而言丢失一部分也没有太大问题,但要提供实时服务,速度是十分重要的因素,这时候就需要考虑使用UDP。

函数调用顺序

由于UDP没有连接,因此UDP中只有创建套接字和数据交换的过程。

TCP中,套接字之间是一对一的关系,若需要向10个客户端提供服务,除了用于接受连接请求的服务端套接字外,还需要10个用于通信的套接字。而在UDP中,不管是服务器还是客户端都只需要1个套接字。我们可将收发信件使用的邮筒比作UDP套接字,只要附近有1个邮筒,就可以通过它向任意地址寄出邮件,同理,只要1个UDP套接字就可以向任意主机传输数据。

服务端:socket -> bind -> recvfrom/sendto -> close

客户端:socket -> sendto/recvfrom -> close

客户端套接字的地址会由sendto函数自动分配。

UDP存在数据边界

输入函数的调用次数会与输出函数的调用次数完全一致,这样才能保证接受全部已发送的数据。

例如,我们在客户端调用了三次输出函数,而在服务端先调用sleep沉睡了5秒,那么如果是TCP,我们也许一次就可以读取完成,但是如果是UDP,我们必须还是调用三次的输入函数才能读取完成。

UDP的连接

UDP通过修改sendto函数的目标ip参数,可以重复利用同一个UDP套接字向不同的目标传输数据,每次传输过程分为以下三个阶段:

  1. 向UDP套接字注册目标ip和端口
  2. 传输数据
  3. 删除注册的目标ip和端口

那如果我们是想与同一主机进行长时间的通信,每次传输的第一和第三阶段都很耗时且没必要,此时将UDP套接字变成已连接的套接字会提高效率。

创建已连接的UDP套接字只需要调用connect函数即可,针对UDP调用connect函数并不意味着要与对方的UDP套接字连接,而只是注册目标ip和端口信息。

优雅地断开套接字连接

套接字是双向通信,因此有两个流:输入流和输出流,而linux中的close函数是同时断开了两个流。当主机A发送完最后的数据后,调用close函数,其他主机发送给主机A的数据也就都被丢弃了。

因此引入了shutdown函数来实现“半关闭”:只关闭一半的流。

第一个参数为套接字,第二个参数即为断开连接的方式:

  • SHUT_RD:只断开输入流
  • SHUT_WR:只断开输出流
  • SHUT_RDWR:同时断开两个流

域名与网络地址

由于服务器的域名基本不会变,而其IP地址会相对频繁的改变,那么为了给用户提供便利的运行方法,我们就需要程序可以由输入的域名转为实际的ip地址进行通信。

利用域名获取ip地址:gethostbyname

利用ip地址获取域名:gethostbyaddr

返回值均为struct hostent*,这一结构体定义如下:

struct hostent {
    char *h_name;   // 官方域名
    char **h_aliases;  // 别名链表
	int h_addrtype;   // 主机的地址类型,如果是ipv4,则存储AF_INET
    int h_length;   // 地址长度
    char **h_addr_list;   // 地址链表
}

注意:地址链表存的地址是char*类型而不是struct in_addr*类型,这是因为这一结构体并不是只为了IPv4准备的,为了通用性便使用了char*类型,那为什么不用void*呢?这是因为套接字的相关函数是在void*标准化前定义的,当时通用类型是采用char*指针。

在使用inet_ntoa时就需要将char*转为struct in_addr*

套接字的多种选项

读取选项:getsockopt

设置选项:setsockopt

函数需要制定协议层和对应层中的选项,协议层有套接字层(SOL_SOCKET)、IP层(IPPROTO_IP)和TCP层(IPPROTO_TCP),我们这里主要说明套接字和TCP层的几个重要选项。

SO_TYPE

整形变量,说明套接字的类型,1是TCP,2是UDP。

SO_SNDBUF & SO_RCVBUF

是输入输出缓冲区大小相关的选项,每个机器都不同,我机器上的输入缓冲是13万多字节,而输出缓冲仅有1万多字节。当然缓冲区的大小也是可以修改的,但我们给定的大小只是建议,因为缓冲大小的设置需谨慎处理,由系统决定。

SO_REUSEADDR

服务端(主动方)在和客户端(被动方)已建立连接的情况下,强制关闭服务端,然后重新运行服务端,会报错:bind函数运行异常,但过了几分钟又可以重新运行,这是为什么呢?

服务端(主动方)在四次握手中接收到客户端(被动方)主动发送的FIN报文后就会进入Time-wait状态,时间持续2个MSL(最大报文存活时间),这是为了确认客户端(被动方)能收到服务端(主动方)对这个FIN报文的ACK。

TIme-wait看似重要,但却不一定讨人喜欢,比如我们服务器发生故障重启,却还需要等待几分钟才能重新运行,这是不合理的。

解决方案就是修改套接字的SO_REUSEADDR选项。我们将SO_REUSEADDR选项修改为1(true),就可以将地址重分配,即把Time-wait状态下的套接字端口号重新分配给新的套接字使用,这样服务端就变成了随时可运行的状态。

TCP_NODELAY

这一TCP选项涉及Nagle算法,这一算法是为了防止数据报过多而导致网络过载而设计的。

如果不使用Nagle算法,数据到达输出缓冲后就立即发送出去,即使该数据仅有一个字节,都需要用几十字节的首部包装然后发送,这样网络的传输效率并不高。

而Nagle算法只有当收到了前一数据的ACK当前输出缓冲中的数据的大小已到达了MSS(最大报文段长度)才可以发送当前数据。

Nagle算法可以充分地利用缓冲,但是会造成一定程度的延迟。根据传输数据的特性,若数据较大,典型的是传输大文件的数据,即便不使用Nagle算法,也会在装满输出缓冲时传输数据包,这时在不需ACK的情况下连续传输,可以大大提高传输速率。因此,要准确的判断数据的特性,不应胡乱使用或禁用Nagle算法。

Nagle算法默认是开启的,如果想禁用,则将TCP协议层的TCP_NODELAY选项置为1即可,但注意:TCP_NODELAY选项在netinet/tcp.h头文件中

多进程服务端

之前的服务器只能同时给一个客户端服务,其他的需要排队,这样的用户体验是很不好的,为了同时向多个客户端提供服务,有以下三种具有代表性的并发服务端实现方法:

  • 多进程服务器
  • 多路复用服务器
  • 多线程服务器

我们这里先讲解第一种:多进程服务器,通过为每个请求的客户端创建一个进程提供服务。

我们使用父进程受理客户端的连接请求,然后为每个连接请求创建一个子进程处理实际的连接通信。这其中设计两个主要的问题:

  1. 如何向子进程传递accept函数返回的用于通信的套接字?
  2. 子进程是否可能变为僵尸进程?如何避免?

对于第一个问题,因为子进程会赋值父进程拥有的资源,因此实际上根本不需要手动传递的过程。

僵尸进程与孤儿进程

而对于第二个问题,首先什么是僵尸进程?为什么会产生它?僵尸进程就是当完成了执行,但在操作系统的进程表中仍然存在这一进程的表项,处于终止状态的进程。它的产生是由于操作系统在一个进程运行结束后并不会立即销毁它,而是等待它的父进程回收(即获取子进程的退出状态)后才销毁(从进程表中删除该表项),因此如果父进程并没有主动回收它的子进程,这一进程就会变为僵尸进程。

如何避免这一状况呢?这就分为了预防和解决,预防如下:

  1. 父进程调用wait/waitpid函数等待子进程的结束,但这样父进程会被阻塞(除非选项设为WNOHANG,但也不方便)
  2. 注册SIGCHLD信号的处理函数,当父进程收到该信号时再去处理

出现僵尸进程后的解决办法:

杀死父进程,这样子进程(也就是该僵尸进程)就变为了孤儿进程,会过继给init进程,由init进程负责回收。

这里要注意:僵尸进程并不等同于孤儿进程,孤儿进程是父进程结束后仍在运行的进程,它不会是僵尸进程, 因为它产生时会立即由init进程收养。

基于多任务的并发服务器

需要注意套接字文件描述符的关闭问题。套接字是一种文件,而fork函数使得子进程复制了父进程的文件描述符,因此父进程和子进程各有一个相同但独立的套接字文件描述符指向服务端的套接字(也算是打开文件),客户端同理。只有所有指向该套接字的文件描述符都被销毁后(即打开文件的引用计数变为0),该套接字才会被销毁。示例代码如下:

while (1) {
    ...
    if (pid == 0) { // 子进程运行区域
    	close(serv_sock);  //关闭子进程中的服务端套接字
    	while ((str_len = reaD(clnt_sock, buf, BUF_SIZE)) != 0) {  // 连接存在
        	write(clnt_sock, buf, str_len);
    	}
    	close(clnt_sock);  // 关闭子进程中的客户端套接字
    	return 0;
	} else {
    	close(clnt_sock);  // 关闭父进程中的客户端套接字
	}
}
close(serv_sock);  // 最终关闭父进程中的服务端套接字

进程间通信

进程具有独立的地址空间,因此需要内核提供一片两个进程都可以访问的内存空间以交换数据。进程通信有多种方式:匿名管道、命名管道、信号、消息队列、共享内存、套接字。这里我们主要讨论匿名管道的使用。

调用pipe函数创建管道。首先在内核中开辟一块缓冲区用于通信,它有一个读端和一个写端,然后通过pipefd参数传出给用户进程两个文件描述符,pipefd[0]指向管道的读端,pipefd[1]指向管道的写段。在用户层面看来,打开管道就是打开了一个文件,通过read()或者write()向文件内读写数据,读写数据的实质也就是往内核缓冲区读写数据。

它有以下特性:

  • 只提供单向通信,一方写,一方读
  • 只能用于具有血缘关系的进程间通信,通常用于父子进程建通信
  • 管道是基于字节流来通信的
  • 依赖于文件系统,它的生命周期随进程的结束而结束
  • 其本身自带同步互斥效果

为什么父子进程可以使用该管道进行通信?因为子进程有父进程文件描述符的副本,它们都可以读写同一管道。

如何实现双向通信?使用两个管道即可,对于一个进程而言,一个用于读取,一个用于写入。但其实我们也可以使用一个管道进行通信,但是不推荐,因为需要注意程序流程的预测和控制,稍不注意也许就会自己读取了自己写入的数据,然后另一进程的read函数一直阻塞。

多线程服务端

使用多进程实现服务端有以下优点:

  1. 程序的并发执行。多道程序设计出现后,进程越多,CPU利用率越高。
  2. 进程独立,稳定性较高。因为进程有独立的地址空间,另一进程的崩溃对本进程无影响。

但它的缺点也很明显:

  1. 正是因为每个进程有独立的地址空间,每次进程上下文切换的开销较大。
  2. 进程的量级较大,因此创建的开销较大。
  3. 为了完成进程间数据的交换,需要使用特殊的IPC技术。

我们为了得到多条并发的逻辑执行流而要复制整个内存映像,这个负担太重了,这也正是线程出现原因。

每个逻辑执行流主要就是函数的调用,因此其实我们不需要复制整个内存映像,而只需要分离栈区域即可。这种方式有以下优点:

  1. 上下文切换不需要切换数据区和堆
  2. 可利用数据区和堆交换数据

这就是线程的做法。

线程的创建与执行

创建线程:pthread_create

注意:进程销毁后,其中的各线程也随之销毁。那我们怎么保证线程可以正常运行完呢?难道需要自己估计时间然后调用sleep吗?不需要。

等待线程结束:

  • pthread_join(当前线程会阻塞等待)
  • pthread_detach(当前线程不会阻塞)

线程安全与可重入

线程安全是在多线程程序的时代下提出的,指函数在多线程、环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。一般可以通过加入同步机制实现线程安全。

而可重入是在单线程程序的时代下提出的,一个函数正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。一般可以让该函数只使用保存在当前函数栈上(参数or局部变量)的数据来实现可重入。

可重入函数未必是线程安全的;线程安全函数未必是可重入的[1]

  • 例如,一个函数打开某个文件并读入数据。这个函数是可重入的,因为它的多个实例同时执行不会造成冲突;但它不是线程安全的,因为在它读入文件时可能有别的线程正在修改该文件,为了线程安全必须对文件加“同步锁”。
  • 另一个例子,函数在它的函数体内部访问共享资源使用了加锁、解锁操作,所以它是线程安全的,但是却不可重入。因为若该函数一个实例运行到已经执行加锁但未执行解锁时被停下来,系统又启动该函数的另外一个实例,则新的实例在加锁处将转入等待。如果该函数是一个中断处理服务,在中断处理时又发生新的中断将导致资源死锁。fprintf函数就是线程安全但不可重入。

线程同步

线程同步用于解决线程访问顺序引发的问题,主要有以下三种方法:

  1. 信号量

​ sem_post

​ sem_wait

  1. 互斥量

    pthread_mutex_lock

    pthread_mutext_unlock

  2. 条件变量

    pthread_cond_wait

    pthread_cond_destroy

参考