返回

网络编程

套接字的概念

Socket本身有“插座”的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。

既然是文件,那么我们就可以使用文件描述符来引用套接字

管道类似,Linux系统将套接字封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致

管道与套接字的区别是:

  • 管道主要应用于本地进程间通信
  • 套接字多应用于网络进程间数据的传递。

在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。

套接字通信原理如下图所示:

  • 一个文件描述符指向一个套接字
  • 一个套接字内部由内核借助两个缓冲区实现,一个写缓冲、读缓冲。
  • 数据流动:发送端→sfd(文件描述符)→cfd(文件描述符)→接收端缓冲区(读缓冲)。并非图上所画直接从发送端到接收端。

在通信过程中, 套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区。

TCP/IP协议最早在BSD UNIX上实现,为TCP/IP协议设计的应用层编程接口称为socket API

套接字编程简介

网络字节序

  • 我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分。
  • 网络数据流的顺序:先发出低地址的数据,后发出高地址的数据。
  • TCP/IP协议规定,网络数据流应采用大端字节序(低地址高字节)。如果本地主机是小端字节序的,则需要考虑网络字节序主机字节序的转换问题。

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换

1
2
3
4
uint32_t htonl(uint32_t hostlong); // 本地字节序转网络字节序(IP)
uint16_t htons(uint16_t hostshort); // 本地字节序转网络字节序(Port)
uint32_t ntohl(uint32_t netlong); // 网络字节序转本地字节序(IP)
uint16_t ntohs(uint16_t netshort); // 网络字节序转本地字节序(Port)
  • h表示hostn表示networkl表示32位长整数,s表示16位短整数
  • 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回
  • IP地址是32位,端口号是16位
  • 因为IP地址是string类型,而字节序转换函数接收参数类型是无符号32位长整数,所以在调用字节序转换函数前,还需要调用atoi函数对IP地址进行类型转换。(了解即可,后面有封装的IP 地址转换函数可直接完成IP地址的类型转换字节序转换)

IP地址转换函数

1
2
// 本地字节序(string IP) ---> 网络字节序
int inet_pton(int af, const char *src, void *dst);
  • 函数名为inet_pton,意思是ip to network,将**String类型的IP地址由本地字节序转换无符号32位长整型的网络字节序**
  • af:IP协议类型,
    • 可选值为AF_INET、AF_INET6
    • 分别代表IPv4和IPv6两种协议。
    • AF_INET、AF_INET6是宏定义。
  • src:传入参数,要转换的String类型的IP地址(点分十进制)
  • dst:传出参数,转换后的网络字节序的IP地址
  • 函数返回值是int类型
    • 转换成功返回1
    • 异常返回0,说明src指向的不是一个有效的ip地址。
    • 失败返回-1
  1. 如果函数接口有指针参数,既可以把指针所指向的数据传给函数使用(称为传入参数),也可以由函数填充指针所指的内存空间,传回给调用者使用(称为传出参数)
  2. 因为void *可以指向任何类型的数据,所以void *指针一般被称为通用指针或者泛指针,或者叫做万能指针。void指针可以赋值给其他任意类型的指针,其他任意类型的指针也可以赋值给void指针,它们之间赋值不需要强制类型转换。
1
2
 // 网络字节序 ---> 本地字节序(string IP)
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
  • 函数名为inet_ntop,意思是network to ip将IP地址由无符号32位长整型的网络字节序转换为String类型的本地字节序
  • afAF_INET、AF_INET6
  • src:传入参数,要转换的无符号32位长整型的IP地址的网络字节序
  • dst:传出参数,转换后的String类型的IP地址的本地字节序
  • sizedst的大小,也就是转换后的String类型的IP地址的本地字节序的长度
  • 返回值:
    • 成功:dst
    • 失败:NULL

套接字地址结构

  • 因为网络编程函数的诞生早于IPv4协议,那时候使用的是sockaddr结构体,后来对该结构体改进产生了后面三个结构体类型

    • sockaddr_in中的in代表internet
    • sockaddr_in是IPv4使用的,sockaddr_in6是IPv6使用的
    • sockaddr_un是本地进程间通信套接字(了解即可)
  • 由于网络编程函数的传入参数仍是旧的sockaddr结构体类型,所以在使用网络编程函数时需要使sockaddr_in结构体类型通过强制类型转换为sockaddr结构体类型

    • c语言中强制类型转换:按照变量的原数据类型从内存取出数据,然后按照另一数据类型的来解释数据
  • sockaddr_in类型的结构体将sockaddr类型的结构体中最后14字节大小的地址数据进行了进一步的细分,将端口号和IP地址分别存在了两个变量中

    • 16位的地址类型(2字节)
    • 16位的端口号(2字节)
    • 32位的IP地址(4字节)
    • 8字节填充
1
2
3
4
5
struct sockaddr
{ 
    unsigned short sa_family; // 2字节,地址族,AF_xxx
    char sa_data[14]; // 14字节,包含套接字中的目标地址和端口信息 
};
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct sockaddr_in {
    sa_family_t		sin_family; // address family:AF_INET
    in_port_t		sin_port;   // port in network byte order
    struct in_addr	sin_addr;   // internet address
    
    char            sin_zero[8] // unused
};

// internet address
struct in_addr {
    uint32_t	s_addr;  
};

【例】以bind函数为例演示套接字地址结构的使用

1
2
3
4
5
6
7
8
// addr是sockaddr_in类型的变量
struct sockaddr_in addr; 
// 对addr变量赋值
addr.sin_family = AF_INET/AF_INET6;
addr.sin_port = htons(9527);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
// 将addr强制类型转换后传入用sockaddr做参数的bind函数
bind(fd, (struct sockaddr *)&addr, size);
  • INADDR_ANY
    • 是一个宏,自动取系统中有效的任意IP地址
    • 让服务器端计算机上的所有网卡的IP地址都可以作为服务器IP地址,也即监听外部客户端程序发送到服务器端所有网卡的网络请求,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址。
    • 二进制类型,也就是长整型,所以不需要类型转换。

总结

  • sockaddrsockaddr_in二者长度一样,都是16个字节,即占用的内存大小是一致的,前2个字节都表示地址类型,不同的是:
    • 内部结构
      • sockaddr用最后的14个字节来表示sa_data
      • sockaddr_in把最后14个字节拆分成sin_port, sin_addrsin_zero分别表示端口、IP地址、填充字节
        • sin_zero填充字节使sockaddr_insockaddr保持一样大小都是16字节
    • 用途
      • sockaddr是给操作系统使用的,因为早期的系统调用函数指定了参数类型必须是sockaddr
      • sockaddr_in是给程序员使用的,因为sockaddr_in区分了IP地址和端口,使用更方便
  • 程序员把类型、IP地址、端口填充给sockaddr_in结构体,然后强制转换成sockaddr,作为参数传递给系统调用函数

基本TCP套接字编程

网络通信流程图

  • 上图表示:借助TCP实现一个CS模型所需要的网络套接字函数
  • 一个网络通信的建立需要三个套接字:一对用来通信,一个用来监听
  • socket()创建一个socket
  • bind()socket绑定ip+port
  • listen()设置监听上限,即同时跟服务器建立socket连接的数量
  • accept():阻塞监听客户端连接,创建一个新的socket用来与客户端通信
  • connect():客户端使用现有的socket与服务器建立连接,如果不使用bind绑定客户端地址结构,采用“隐式绑定”,系统自动分配ip+port

TCP协议通讯流程

  • 连接的建立:三次握手的过程

    • 服务器调用socket()bind()listen()完成初始化后,调用**accept()阻塞等待**,处于监听端口的状态
    • 客户端调用socket()初始化后,调用**connect()发出SYN段(SYN状态标志位有效)并阻塞等待**服务器应答
    • 服务器应答一个SYN-ACK
    • 客户端收到后从**connect()返回**,同时应答一个ACK段,服务器收到ACK段后从**accept()返回**
  • 数据传输的过程

    • 建立连接后,TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。
    • 服务器accept()返回后立刻调用read(),读socket的缓冲区就像读管道一样,如果没有数据到达**read()就阻塞等待**
    • 客户端调用write()发送请求给服务器,然后,调用**read()阻塞**等待服务器的应答,
    • 服务器收到客户端的请求进行读取后**read()返回**,处理请求后,调用write()将处理结果发回给客户端,再次调用**read()阻塞**等待下一条请求
    • 客户端收到后从**read()返回**,发送下一条请求,如此循环下去
  • 连接的释放:

    • 如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。

    • 注意:

      • 半关闭状态:通信双方中,只有一端关闭通信

      • 调用close()后,调用方关闭通信,调用方的读和写缓冲区都关闭

      • 调用shutdown()后,调用方关闭通信,但可指定函数参数来只关闭写缓冲或读缓冲

TCP状态转换

  • 实线表示主动发起连接一端的状态
  • 虚线表示被动接收连接一端的状态

主动发起连接请求端

  • CLOSED:表示初始状态
  • SYN_SENT:主动打开状态,表示客户端已发送SYN报文。这个状态与SYN_RCVD遥相呼应,当客户端发送SYN报文(第一次握手)后,随即进入到SYN_SENT状态,并等待服务端的发送三次握手中的第2个报文。
  • ESTABLISHED:数据传输状态,表示连接已经建立。当客户端收到服务器应答的SYN-ACK报文(第二次握手),并回复ACK报文(第三次握手)后,随即进入到ESTABLISHED状态

主动关闭连接请求端

  • FIN_WAIT_1:主动关闭连接的一方,发出FIN报文后进入该状态
  • FIN_WAIT_2半关闭状态,该状态下的socket只能接收数据,不能发送数据。当主动关闭连接的一方,发出FIN收到ACK以后进入该状态
  • TIME_WAIT: 当收到了对方的FIN报文,并发送出了ACK报文后,进入到TIME_WAIT状态,
  • CLOSETIME_WAIT状态2MSL即可回到CLOSED初始状态
    • 如果FIN_WAIT_1状态下,收到对方同时带FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态
    • 只有主动关闭连接方会经历该状态
  • 2MSL时长:一定出现在【主动关闭连接请求端】。对应TIME_WAIT状态,保证最后一个ACK能成功被对端接收。(等待2MSL时长期间,对端没收到我发的ACK,对端会再次发送FIN请求)

被动接收连接

  • CLOSED:表示初始状态
  • LISTEN:监听状态,表示服务器端可以接受连接请求
  • SYN_RCVD: 当接收到SYN报文(第一次握手),并回复SYN-ACK报文(第二次握手)后,进入SYN_RCVD状态
  • ESTABLISHED:数据传输状态,表示连接已经建立。当服务器接收到客户端应答的ACK报文(第三次握手)后,随即进入到ESTABLISHED状态

被动关闭连接

  • CLOSE_WAIT:当接收到主动关闭方发送的FIN并应答一个ACK报文后,进入到CLOSE_WAIT状态。接下来查看是否还有数据发送给对方,如果没有,则发送FIN报文给对方
    • 说明对端(主动关闭连接端)处于半关闭状态
  • LAST_ACK: 当被动关闭一方在发送FIN报文后,进入LAST_ACK状态
  • CLOSE:当被动关闭一方在LAST_ACK状态接收到对方发送的ACK后进入CLOSE初始状态,即关闭连接

socket函数

1
2
// 创建一个套接字
int socket(int domain, int type, int protocol); 
  • domain:IP协议,AF_INETAF_INET6AF_UNIX/AF_LOCAL
  • type数据传输协议,流式协议SOCK_STREAM,报式协议SOCK_DGRAM
  • protocol数据传输协议中的代表协议
    • 默认0,根据type选择指定协议。
    • 流式协议SOCK_STREAM代表协议TCP
    • 报式协议SOCK_DGRAM代表协议UDP
  • 返回值
    • 成功:新套接字对应的文件描述符
    • 失败:-1 errno

【例】用socket()函数创建一个套接字,使用IPv4协议、流式协议,返回该套接字的文件描述符

1
fd = socket(AF_INET, SOCK_STREAM, 0)

bind函数

1
2
// 给socket绑定一个地址结构(ip+port)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); 
  • sockfd:套接字对应的文件描述符
  • addr(struct sockaddr)&addr
    • addr要先初始化
  • addrlensizeof(addr),地址结构的大小
  • 返回值
    • 成功:0
    • 失败:-1 errno

【例】用bind函数绑定socket地址结构

1
2
3
4
5
6
7
8
// addr是sockaddr_in类型的变量
struct sockaddr_in addr; 
// 对addr变量赋值
addr.sin_family = AF_INET/AF_INET6;
addr.sin_port = htons(9527);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
// 将addr强制类型转换后传入用sockaddr做参数的bind函数
bind(fd, (struct sockaddr *)&addr, sizeof(addr));

listen函数

1
2
// 设置同时与服务器建立连接的上限数(同时进行三次握手的客户端)
int listen(int sockfd, int backlog); 
  • sockfd:套接字对应的文件描述符
  • backlog:上限数值,最大值128
  • 返回值
    • 成功:0
    • 失败:-1 errno

accept函数

1
2
// 阻塞等待客户端建立连接,成功的话,返回一个与客户端成功连接的socket文件描述符 
accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); 
  • sockfd:套接字对应的文件描述符

  • addr:传出参数。成功与服务器建立连接的客户端的地址结构(ip+port)

  • addrlen:传入传出参数。&client_addr_len

    • 传入的是调用者提供的addr缓冲区的长度以避免缓冲区溢出问题
      • 使用前需要先定义addr缓冲区的长度
      • 传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给addr参数传NULL,表示不关心客户端的地址
  • 返回值

    • 成功:能与服务器进行数据通信的客户端socket对应的文件描述符
    • 失败:-1 errno

connect函数

1
2
// 客户端使用现有的socket与服务器建立连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); 
  • sockfd:客户端套接字对应的文件描述符
  • addr:传入参数。服务器的地址结构
  • addrlen:服务器的地址结构的大小
  • 返回值
    • 0:成功
    • -1 errno:失败
  • 如果不使用bind绑定客户端地址结构,采用“隐式绑定”,系统自动分配ip+port

【例】用connect函数请求连接服务器

1
2
3
4
5
6
struct sockaddr_in srv_addr;
// 配置服务器的地址结构
srv_addr.sin_family = AF_INET; 
srv_addr.sin_port = 9527;
inet_pton(AF_INET, {String类型IP地址的本地字节序}, &srv_addr.sin_addr.s_addr);
connect(fd, (struct sockaddr *)&srv_addr, sizeof(srv_addr))

setsockopt函数

1
int setsockopt(int sockfd, int level, int optname, const void *optval, ,socklen_t optlen);
  • 函数功能:用来设置参数sockfd所指定的socket的状态
  • level:代表欲设置的网络层,
    • SOL_SOCKETsocket
  • optname:代表欲设置的选项参数
    • SO_REUSEADDR:地址复用
  • optval:代表欲设置的值
    • 1:启用
    • 0:不启用
  • optlen:为optval的长度

多进程并发服务器

使用多进程并发服务器时要注意:

  • 防止僵尸进程
  • 捕捉SIGCHLD信号
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 多进程并发服务器
Socket();
Bind();
Listen();
while(1){
    connectfd = Accept(); // 接收客户端连接请求
    pid = fork(); // 创建子进程   
  	if ( pid == 0 ){ // 子进程
        Close(listenfd); // 关闭用于监听的套接字
        read();
        // 将读到的字节进行操作
        write();
    } else if (pid > 0) {
        Close(connectfd); // 关闭用于与客户端通信的套接字
        // 用信号捕捉函数回收子进程的相关代码
        continue;
    }
}
  • Socket():创建监听套接字listenfd

    • listenfd是用来建立连接的套接字
    • 当客户端有连接请求时,服务器会借助listenfd创建一个connectfd用来与客户端进行通信数据传输。listenfd被解放出来,用于监听其他客户端的请求
    • 在多进程并发服务器中,父进程用于监听客户端请求,子进程用于与客户端通信
  • Bind():绑定地址结构 struct socketaddr_in servaddr;

  • Listen():设置同时与服务器建立连接的上限数(同时进行三次握手的客户端)

  • Accept():阻塞等待客户端建立连接,成功的话,返回一个与客户端成功连接的connectfd

    • 在多进程并发服务器中,每个connectfd都有一个子进程与之对应;
    • 在多进程并发服务器中,由于支持多个客户端与服务器建立连接,所以Accept应该循环执行,一直监听客户端的连接请求
  • fork():产生子进程

  • Close()fork产生子进程后,子进程和父进程一样,都有listenfdconnectfd两个套接字。子进程不需要监听套接字,只需要与客户端通信的套接字,所以关闭listenfd;父进程不需要与客户端通信的套接字,只需要监听套接字用来监听客户端请求,所以关闭connectfd

  • read():读缓冲

  • 将读到的字节进行操作

  • write():写缓冲

  • signal():信号捕捉,如果捕捉到SIGCHLD信号,说明子进程结束,就调用处理函数,完成子进程回收

  • waitpid:因为waitpid()可以设置不阻塞,所以使用waitpid()

    • 由于设置waitpid()为非阻塞,所以当子进程没结束时,返回0,需要再次执行去回收,防止所有子进程都变为僵尸进程,所以需要设置为循环执行waitpid(),子进程一旦结束,一定要立刻回收。但循环执行的话,父进程可能会阻塞于waitpid(),此时如果有新的客户端连接请求,父进程无法响应。
    • 结局办法:将上述操作放入信号处理函数中
  • continue:结束本次循环,父进程监听下一个客户端的连接请求

多路I/O转接服务器

原理:借助内核,selectpoll来监听客户端连接、数据通信事件

select函数

1
int select (int maxfd + 1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval * timeout);
  • 函数功能:用于监视文件描述符的变化情况——读/写/异常

  • nfds:监听的所有文件描述符中最大的一个+1,此参数会告诉内核监听多少个文件描述符

  • readset:传入传出参数,读文件描述符的监听集合,只监听该集合中文件描述符的读事件

    • 传入传出参数:传入的是想要监听的文件描述符,函数执行完,传出的是满足对应事件的文件描述符
  • writeset:传入传出参数,写文件描述符的监听集合,只监听该集合中文件描述符的写事件

    • NULL:不监听文件描述符的写事件
  • exceptset:传入传出参数,异常文件描述符的监听集合,只监听该集合中文件描述符的异常事件

    • NULL:不监听文件描述符的异常事件
  • timeout:阻塞监听时间

    • NULL:阻塞,一直等
    • timevaltimeval是一个结构体,有两个成员变量:tv_sectv_usec,分别是秒和微秒
      • >0:设置成员变量为固定时间
      • 0:不阻塞,检查描述字后立即返回,轮询
  • 返回值:

    • >0:所有的文件描述符监听集合中满足对应事件的总个数
    • 0:没有满足对应事件的文件描述符
    • -1errno
  • 优点

    • 跨平台:winlinuxmacOSUnix类Unixmips
  • 缺点

    • 监听上限受文件描述符限制,最大 1024
    • 无法直接定位满足监听事件的文件描述符,只能用循环轮询或自己构建数组保存各个connfd
1
2
3
4
5
6
7
8
// 清空文件描述符集合中(所有位置零)
void FD_ZERO (fd_set *fdset); 
// 将一个文件描述符添加到文件描述符的监听集合中
void FD_SET (int fd,fd_set *fdset); 
// 将一个文件描述符从文件描述符的监听集合中移除
void FD_CLR (int fd,fd_set *fdset); 
// 判断一个文件描述符是否在文件描述符的监听集合中,在的话返回1;不在返回0
int FD_ISSET(int fd,fd_set *fdset); 

思路分析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int maxfd = 0
lfd = socket(); 	// 创建套接字
maxfd = lfd
bind(); 	// 绑定地址结构
listen();	// 设置监听上限
fd_set rset,allset;	// rset 读事件文件描述符集合 allset 用来暂存(b)
FD_ZERO(&allset);	// 将allset监听集合清空
FD_SET(lfd, &allset);	// 将 lfd 添加至allset监听集合中。
while1 {
	rset = allset;	// rset作为传入传出参数
	ret  = select(lfd+1 &rset NULL NULL NULL);	// 监听文件描述符集合对应事件
	ifret > 0 {							// 有监听的描述符满足对应事件	
		if (FD_ISSET(lfd, &rset)) {				// rset作为传出参数;1在,0不在
			cfd = accept()				// 建立连接,返回用于通信的文件描述符
			maxfd = cfd
			FD_SET(cfd, &allset);				// 添加cfd到监听集合中
		}
		for(i = lfd+1; i <= 最大文件描述符; i++) { // 遍历
			if (FD_ISSET(i, &rset)){				// 有read、write事件发生
				read()
				操作字节;
				write();
             }
		}	
	}
}

poll函数

1
2
3
4
5
6
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct polled {
    int fd;
    short events;
    short revents
}
  • 函数功能:用于监视文件描述符的变化情况——读/写/异常
  • fds:监听的文件描述符的结构体【数组】,此处的*就是首地址
    • fd:监听的文件描述符
    • events:监听事件,用宏定义
      • POLLIN:有数据可读
      • POLLOUT:写事件
      • POLLERR:异常事件
      • POLLRDNORM:有普通数据可读
    • revents:初始化为0,如果满足对应事件则返回非0,即POLLINPOLLOUTPOLLERR
  • nfds:监听数组中的实际监听个数
  • timeout:阻塞监听时间,单位:毫秒
    • -1:阻塞,一直等
    • 0:不阻塞,检查描述字后立即返回,
    • >0:等待指定毫秒数
  • 返回值:
    • >0:所有的文件描述符监听集合中满足对应事件的总个数
    • 0:没有满足对应事件的文件描述符
    • -1errno
  • 优点
    • 自带数组结构,分离了传入传出参数
    • 可以拓展监听上限,超出 1024限制
  • 缺点
    • 不能跨平台,只能UNIX/LINUX,不能WINDOWS
    • 无法直接定位满足监听事件的文件描述符,只能用循环轮询或自己构建数组保存各个connfd

基本UDP套接字编程

TCP和UDP对比

  • TCP:传输控制协议

    • 面向连接的,可靠数据包传输。
    • 对于不稳定的网络层,采取完全弥补的通信方式——丢包重传
    • 优点:
      • 数据流量稳定:在建立TCP连接后,数据传输流量是稳定的
      • 传输速度稳定:在建立TCP连接后,数据传输速度是稳定的
      • 传输顺序一致:在网络环境不发生变化的情况下,各个数据包经过的路由节点一致,数据报到达顺序与发送时一致
    • 缺点:
      • 传输速度慢
      • 传输效率低
      • 资源开销大
    • 使用场景
      • 数据的完整型要求较高,不追求效率
        • 大数据传输、文件传输。
  • UDP:用户数据报协议

    • 无连接的,不可靠的数据报传递
    • 对于不稳定的网络层,采取完全不弥补的通信方式——默认还原网络状况
    • 优点:
      • 传输速度快
      • 传输速率高
      • 资源开销小
    • 缺点:
      • 数据流量不稳定
      • 传输速度不稳定
      • 传输顺序不一致:各个数据报经过的路由节点可能不一致,数据报到达顺序可能与发送时不一致
    • 使用场景
      • 对时效性要求较高场合,稳定性其次
        • 游戏、视频会议、视频电话
    • 可以通过采用应用层数据校验协议,弥补udp的不足,保证数据包有效传递

UDP通信流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
//server:
	lfd = socket(AF_INET, SOCK_DGRAM, 0);
	bind();
	while(1){
        recvfrom();
        // 操作字节数据
        sendto();
	}
	close();
//client:
	connfd = socket(AF_INET, SOCK_DGRAM, 0);
	sendto('服务器的地址结构','地址结构大小');
	recvfrom();
	// 操作字节数据
	close();
  • 因为不用三次握手,所以服务器不需要accept(),客户端不需要connect()
  • 因为listen()是用来设置服务器同时与客户端进行三次握手的上限,所以listen()可有可无
  • 由于listenfd不负责通信,所以read()被替换为recvfrom()write()被替换为sendto()
  • 客户端直接使用sendto()向服务器发起连接

recvfrom函数

1
int recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr,socklen_t *addrlen);
  • 函数功能:接收socket传来的数据, 并把数据存到buf指向的内存空间
  • sockfd:对服务器来说就是listenfd
  • buf:接收数据的缓冲区
  • len:缓冲区大小
  • flags:调用操作方式
    • 0:常规操作,与read()相同
  • src_addr:传出参数,传出对端地址结构
  • addrlen:传入传出参数,地址结构长度
  • 返回值:
    • >0:成功接收到的字节数
    • 0:对端关闭连接
    • -1errno

sendto函数

1
int sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
  • 函数功能:将数据由socket传给对方主机
  • sockfd:套接字
  • buf:存储数据的缓冲区
  • len:数据长度
  • flags:调用操作方式
    • 0:常规操作,与writ()相同
  • src_addr:传入参数,传入目标地址结构
  • addrlen:传入传出参数,地址结构长度
  • 返回值:
    • >0:成功写出的字节数
    • 0:对端关闭连接
    • -1errno

Linux和C语言知识

包裹函数

  • 包裹函数用于错误处理
  • 任何现实世界的程序都必须检查每个函数调用是否返回错误
  • 每个包裹函数完成实际的函数调用,检查返回值,并在发生错误时终止进程
  • 约定包裹函数名是实际函数名的首字母大写形式

Unix errno值

  • 只要一个Unix函数中有错误发生,全局变量errno就被置为一个指明该错误类型的正值,函数本身则通常返回-1
  • err_sys查看errno变量的并输出相应的出错消息后退出进程
    • 例如:当error的值等于ETIMEDOUT时,将输出Connection timed out
  • error的值只在函数发生错误时设置,如果函数不返回错误,error的值就没有定义
  • error的所有正数错误值都是常值,具有以E开头的全大写字母名字,值0不表示任何错误

sizeof操作符

  • sizeofC语言的一种单目操作符,如C语言的其他操作符++--
  • sizeof操作符以字节形式给出了其操作数的存储大小
  • 操作数可以是一个表达式或括在括号内的类型名。操作数的存储大小由操作数的类型决定

文件描述符

文件描述符是Linux编程中的一个术语。当一个文件打开后,系统会分配一部分资源来保存该文件的信息,以后对文件的操作就可以直接引用该部分资源了。文件描述符可以认为是该部分资源的一个索引,在打开文件时返回。

当程序执行时,就已经有三个文件流打开了,它们分别是标准输入stdin,标准输出stdout和标准错误输出stderr。和流式文件相对应的是,也有三个文件描述符被预先打开,它们分别是STDIN_FILENO0STDOUT_FILENO1STDERR_ FILENO2,代表标准输入、标准输出和标准错误输出。

需要指出的是,上面的流式文件输入、输出和文件描述符的输入输出方式不能混用,否则会造成混乱

fileno函数

1
int fileno(FILE *stream);
  • 函数功能:把文件流指针转换成文件描述符
  • 返回值:
    • 指定文件流的文件描述符:成功
    • -1:失败

fputs函数

1
int fputs(const char *str, FILE *stream)
  • 函数功能:把字符串写入到指定的流stream中,但不包括空字符
  • str :要写入的字符数组,以空字符终止
  • stream :要被写入字符串的流,是指向FILE对象的指针
  • 返回值:
    • 非负值:成功
    • EOF:发生错误

fgets函数

1
char *fgets(char *str, int n, FILE *stream)
  • 函数功能:从指定的流stream读取一行,并把它存储在str所指向的字符串内。当读取(n-1)个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定
  • str:存储读取的字符的字符数组。
  • n:要读取的最大字符数(包括最后的空字符)
  • stream:指向FILE对象的指针,标识了要从中读取字符的流
  • 返回值:
    • str参数:成功
    • NULL
      • 到达文件末尾或者没有读取到任何字符,str的内容保持不变,并返回一个NULL
      • 发生错误

bzero函数

1
void bzero(void *s, int n);
  • 函数功能:将内存块或字符串的前n个字节清零
  • s:内存或字符串指针
  • n:需要清零的字节数

头文件

  • #include <>格式:引用标准库头文件,编译器从标准库目录开始搜索
  • #include""格式:引用非标准库的头文件,编译器从用户的工作目录开始搜索

命令行参数

1
2
3
4
5
6
7
void main(int argc,char** argv)
{
        printf("%d\n",argc);
        printf("%s\n",argv[0]);
        printf("%s\n",argv[1]);
        printf("%s\n",argv[2]);
}
  • argcint类型,表示命令行参数的个数
    • 不允许用户传递,自动根据用户从命令行输入的参数个数确定
  • argvchar**类型,用于存储用户从命令行传递进来的参数
    • 它的第一个成员是用户运行的程序名字

read和write函数

read函数

1
ssize_t read(int fd, void *buf, size_t count)
  • 作用:从文件描述符相关联的文件里读入count个字节的数据,并把它们放到数据区buf中。
  • 返回值:实际读入的字节数,这可能会小于请求的字节数,
    • 0,已经读到套接字末尾(对端已经关闭)
    • -1,表示read()调用出现了错误,错误代码保存在全局变量errno
      • EINTR:被异常中断,需要重启
      • EAGIN/EWOULDBLOCK:以非阻塞方式读数据,但没有数据,需要再次读
      • ECONNRESET:连接被重置,需要close(),移出监听队列
    • >0,表示读取的字节数

write函数

1
ssize_t write(int fd, const void *buf, size_t count) 
  • 作用:把缓冲区的前count个字节写入与文件描述符关联的文件。
  • 返回值:write()返回实际写入的字节数
    • 返回0,表示未写入任何数据
    • 返回-1,表示write()调用中出现了错误,错误代码保存在全局变量errno中。
    • 返回>0,表示写入的字节数
  • write()可能会报告写入的字节比要求的少,这不一定是错误,在程序中,需要检查errno以发现错误,然后再次调用write()写入剩余数据

readn,writen和readline函数

read()write()函数在字节流套接字上表现的行为和通常的文件I/O不同,字节流套接字上调用read()write()输入或输出的字节数可能比请求的数量少,因为内核中用于套接字的缓冲区是有限制的,需要调用者多次调用read()write()函数。

提示:readn()writen()readline()是对read()wirte()的封装

1
2
3
4
5
6
// 从文件描述符fd中读取n个字节,存入vptr指针的位置
readn(int fd, void *vptr, size_t n)
// 向文件描述符fd中写入n个字节,从vptr位置开始写
writen(int fd,const void* vptr, size_t n)
// 从文件描述符fd中读一行文本,长度不超过maxlen,一次读1个字节
readline(int fd,void *vptr, size_t maxlen)
  • readn()函数功能:在网络编程的读取数据中,通常会需要用到一个读指定字节才返回的函数,linux系统调用中没有给出,需要自己封装。

    • 当剩余长度大于0的时候就一直读啊读
    • 当read的返回值小于0的时候,做异常检测
    • 当read的返回值等于0的时候,退出循环
    • 当read的返回值大于0的时候,拿剩余长度减read的返回值,拿到新的剩余长度,读的入口指针加上read的返回值,进入步骤1
    • 返回参数n减去剩余长度,即实际读取的总长度
  • writen()函数功能:写满n个字节才返回

    • 当要写入的剩余长度大于0的时候就一直写啊写
    • 当write的返回值小于0的时候,做异常检测
    • 当write的返回值等于0的时候,出错退出程序
    • 当write的返回值大于0的时候,拿剩余长度减去write的返回值,拿到新的剩余长度,写的入口指针加上write的返回值,进入步骤1
    • 返回参数n的值,即期望写入的总长度
  • readline()函数功能:读到'\n'或者读满缓冲区才返回

    • 当读取的次数小于maxlen的时候就一直读啊读
    • 当read的返回值等于1的时候,将读到的值赋值给ptr++,判断是不是\n
    • 当read的返回值等于0的时候,读完了
    • 当read的返回值小于0的时候,做异常检测
    • 进入步骤1
    • 返回实际读取的长度

close和shutdown函数

close函数

1
int close(int sockfd);     //返回值:成功为0,出错为-1.
  • close 一个套接字的默认行为是把套接字标记为已关闭,然后立即返回到调用进程,该套接字描述符不能再由调用进程使用,也就是说它不能再作为read()write()的第一个参数
  • 然而,TCP将尝试发送已排队等待发送到对端的任何数据发送完毕后发生的是正常的TCP连接终止序列
  • 多进程并发服务器中,父子进程共享着套接字套接字描述符引用计数记录着共享着的进程个数,当父进程某一子进程close掉套接字时,描述符引用计数会相应的减一,当引用计数大于零时,这个close调用就不会引发TCP的四路握手断连过程

shutdown函数

1
int shutdown(int sockfd,int howto);  //返回值:成功为0,出错为-1.

该函数的行为依赖于howto的值

  • SHUT_RD:值为0,关闭连接的读这一半。
  • SHUT_WR:值为1,关闭连接的写这一半。
  • SHUT_RDWR:值为2,连接的读和写都关闭。

终止网络连接的通用方法是调用close()函数。但使用shutdown()能更好的控制断连过程(使用第二个参数)

close和shutdown区别

  • 使用close中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为0时才关闭连接。

  • shutdown不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。 注意:

    • 如果有多个进程共享一个套接字,close每被调用一次,计数减1,直到计数为0时,也就是所有进程都调用了close,套接字将被释放。

    • 在多进程中如果一个进程调用了shutdown(sockfd, SHUT_RDWR)后,其它的进程将无法进行通信。但,如果一个进程close(sockfd)将不会影响到其它进程。

  • shutdown在关闭多个文件描述符引用的文件时,采用全关闭方法;close只关闭一个

sleep函数

  • sleep(time)函数以秒为单位,将进程由运行态转入阻塞态,使进程睡眠;当睡眠时间结束,进程由阻塞态转为就绪态,等待CPU空闲后进入运行态继续执行剩下的指令
  • 这个函数是可以被中断的
    • 当进程在睡眠的过程中,如果被中断,那么当中断结束,回来再执行该进程的时候,该进程会从sleep()函数的下一条语句执行;这样的话就不会睡眠time秒了

进程控制

fork函数

1
pid_t fork(void)
  • 函数功能:创建子进程。

  • 原理分析:执行fork()后,由父进程复制产生子进程,两个进程都由fork()之后的代码继续向下执行,父进程返回子进程的pid,子进程返回0

    • 当返回值是0,说明这是子进程;当返回值>0,也就是子进程的pid,说明这是父进程;
    • 父子进程相同:data段、code段、堆、栈、环境变量、全局变量、宿主目录位置、进程工作目录位置、信号处理方式
    • 父子进程不同: pidfork返回值、各自的父进程、进程创建时间、闹钟、未决信号集
    • 父子进程共享: 读时共享、写时复制。读的时候不复制,父子进程用同一份数据;写的时候需要一份复制后写,写后不影响另一进程
  • 返回值:

    • 成功:父子进程各自返回,父进程返回子进程的pid,子进程返回0
    • 失败:-1 errno

exec函数族

exec函数族中的函数可以使fork()出的子进程不执行复制来的父进程的代码,转而执行另一个程序,子进程的代码和数据完全被新程序替换

回收子进程

孤儿进程

父进程先于子进程终止,子进程成为孤儿进程,会被 init 进程领养

僵尸进程

进程终止,父进程存在但尚未回收该子进程,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程

僵尸进程无法使用kill命令清除,因为kill命令只是用来终止进程的,而僵尸进程已经终止

wait函数

一个进程终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的 PCB 还保留着。内核在PCB保存了一些信息:

  • 如果是正常终止则保存着退出状态,
  • 如果是异常终止则保存着导致该进程终止的信号

这个进程的父进程可以调用 wait() 或者 waitpid() 获取PCB中保存的信息,然后彻底清除掉这个进程

1
pid_t wait(int *status);
  • 函数功能:父进程调用wait()函数可以回收子进程,该函数具体有以下两个功能
    • 阻塞等待子进程退出wait()函数一直阻塞等待子进程的结束;子进程结束后,回收该子进程,wait()函数返回
    • 获取子进程残留资源:回收PCB中记录的子进程的退出状态和子进程结束的原因
  • status:传出参数,保存子进程残留资源
    • NULL:不保存子进程残留资源
  • 返回值:
    • 成功:子进程ID
    • 失败:-1
waitpid函数
1
pid_t waitpid(pid_t pid, int *status, in options);
  • 函数功能:

    • 作用同wait():用来回收子进程;
    • 但可以指定pid回收,
    • 且可以通过设置options,使子进程即使没结束,也可以直接回收,使waitpid()函数不阻塞
  • pid

    • >0:回收指定ID的子进程
    • 0:回收同一进程组的任意子进程
    • -1:回收任意子进程
  • status

    • NULL:不关心子进程的结束状态
  • options

    • WNOHANG:不阻塞模式
      • 如果该子进程没有结束,则不回收子进程,返回0
      • 如果该子进程已经结束,则回收该子进程,返回pid
  • 返回值:

    • >0:成功,子进程ID
    • -1:失败
    • 0options指定为WNOHANG,且子进程未结束

进程通信

进程间通信的常用方式:

  • 管道:使用最简单
  • 信号:占用CPU和内存的开销最小,携带数据量有限,表示的数据单一,应用在对速度要求高的进程之间
  • 共享映射区:应用在无血缘关系的进程之间
  • 本地套接字:最稳定

信号的概念

  • 信号是软件层面上的“中断”。
  • 一旦信号产生,无论程序执行到什么位置,必须立即停止运行转而处理信号,待信号处理结束再继续执行后续指令
  • 所有信号的产生处理全部都是由内核完成的
  • 每个进程收到的所有信号,都是由内核负责发送的,内核处理

信号的产生

产生信号的方式:

  • 按键产生
  • 系统调用产生
  • 软件条件产生
  • 硬件异常产生
  • 命令产生

信号集的概念

  • 递达:产生并且送达到进程。直接被内核处理掉。
  • 未决:产生与递达之间的状态。
  • 信号处理方式:
    • 默认处理动作
    • 忽略
    • 捕捉(自定义处理动作)
  • 阻塞信号集(信号屏蔽字): 本质是位图。用来记录信号的屏蔽状态。一旦被屏蔽的信号,在解除屏蔽前,一直处于未决状态
  • 未决信号集:本质是位图。用来记录信号的处理状态。该信号集中的信号,1表示已经产生,但尚未被处理现处于未决状态;0表示处理完成
  • 信号集存储在PCB中

信号的四要素

  • 使用kill -l命令可以查看当前系统可使用的信号

    • 不存在编号为0的信号
    • 1-31号信号是常规信号(普通信号/标准信号)
    • 34-64号信号是实时信号,与驱动编程和硬件相关
  • 信号的四要素

    • 信号编号
    • 信号名称
    • 信号对应事件
    • 信号默认处理动作
      • Term:终止进程
      • Ign:忽略信号
      • Core:终止进程,生成Core文件
      • Stop:停止(暂停)进程
      • Cont:继续运行进程
  • 只有信号对应的事件发生了,该信号才会被递送(但不一定递达)

信号捕捉

信号捕捉:捕捉到信号后,自定义处理动作

signal函数

注册一个信号捕捉函数

1
2
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
  • 该函数由ANSI定义,由于历史原因在不同版本的Unix和不同版本的Linux中可能有不同的行为
  • 第一句:定义了一个指针变量sighandler_t,该指针变量可以指向返回值类型为空,且有一个整型参数的函数sighandler_t的类型为void(*)(int)
  • signum:注册捕捉哪个信号
  • handler:捕捉到信号后的行为

SIGCHLD信号

  • 子进程状态发生变化时,父进程会收到SIGCHLD信号
    • 子进程终止时
    • 子进程接收到SIGSTOP信号停止
    • 子进程处在停止态,接收到SIGCONT唤醒
  • 默认动作为:忽略这个信号
  • 可以通过信号捕捉,自定义处理动作

守护进程

  • 守护进程(Daemon)是后台进程,没有控制终端,周期性(循环)地执行某种任务或等待处理某些发生的事件,命名一般以d结尾
  • 创建守护进程,最关键的一步是使用setid函数创建一个新的Session,并成为Session Leader

会话是多个进程组的集合,进程组是多个进程的集合

Built with Hugo
Theme Stack designed by Jimmy