套接字的概念
Socket
本身有“插座”的意思,在Linux
环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。
既然是文件,那么我们就可以使用文件描述符来引用套接字。
与管道类似,Linux
系统将套接字封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。
管道与套接字的区别是:
- 管道主要应用于本地进程间通信
- 套接字多应用于网络进程间数据的传递。
在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket
。欲建立连接的两个进程各自有一个socket
来标识,那么这两个socket
组成的socket pair
就唯一标识一个连接。因此可以用Socket
来描述网络连接的一对一关系。
套接字通信原理如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/10b9644599f812f198c65ce1b054f9f4.png)
- 一个文件描述符指向一个套接字
- 一个套接字内部由内核借助两个缓冲区实现,一个写缓冲、读缓冲。
- 数据流动:发送端→
sfd
(文件描述符)→cfd
(文件描述符)→接收端缓冲区(读缓冲)。并非图上所画直接从发送端到接收端。
在通信过程中, 套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区。
TCP/IP
协议最早在BSD UNIX
上实现,为TCP/IP
协议设计的应用层编程接口称为socket API
。
![](https://i-blog.csdnimg.cn/blog_migrate/f607745dfe667aedffbf0e12a51df545.png)
套接字编程简介
网络字节序
- 我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分。
- 网络数据流的顺序:先发出低地址的数据,后发出高地址的数据。
- TCP/IP协议规定,网络数据流应采用大端字节序(低地址高字节)。如果本地主机是小端字节序的,则需要考虑网络字节序和主机字节序的转换问题。
为使网络程序具有可移植性,使同样的C
代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
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
表示host
,n
表示network
,l
表示32位长整数,s
表示16位短整数- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回
- IP地址是32位,端口号是16位
- 因为IP地址是
string
类型,而字节序转换函数接收参数类型是无符号32位长整数,所以在调用字节序转换函数前,还需要调用atoi
函数对IP地址进行类型转换。(了解即可,后面有封装的IP 地址转换函数可直接完成IP地址的类型转换和字节序转换)
IP地址转换函数
// 本地字节序(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
- 转换成功返回
- 如果函数接口有指针参数,既可以把指针所指向的数据传给函数使用(称为传入参数),也可以由函数填充指针所指的内存空间,传回给调用者使用(称为传出参数)
- 因为
void *
可以指向任何类型的数据,所以void *
指针一般被称为通用指针或者泛指针,或者叫做万能指针。void
指针可以赋值给其他任意类型的指针,其他任意类型的指针也可以赋值给void指针,它们之间赋值不需要强制类型转换。
// 网络字节序 ---> 本地字节序(string IP)
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
- 函数名为
inet_ntop
,意思是network to ip
,将IP地址由无符号32位长整型的网络字节序转换为String
类型的本地字节序 af
:AF_INET、AF_INET6
src
:传入参数,要转换的无符号32位长整型的IP地址的网络字节序dst
:传出参数,转换后的String
类型的IP地址的本地字节序size
:dst
的大小,也就是转换后的String
类型的IP地址的本地字节序的长度- 返回值:
- 成功:
dst
- 失败:
NULL
- 成功:
套接字地址结构
![](https://i-blog.csdnimg.cn/blog_migrate/b3307c85bcc34758930808786ccd3aa7.png)
-
因为网络编程函数的诞生早于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字节填充
struct sockaddr
{
unsigned short sa_family; // 2字节,地址族,AF_xxx
char sa_data[14]; // 14字节,包含套接字中的目标地址和端口信息
};
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
函数为例演示套接字地址结构的使用
// 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地址。
- 二进制类型,也就是长整型,所以不需要类型转换。
总结
sockaddr
和sockaddr_in
二者长度一样,都是16个字节,即占用的内存大小是一致的,前2个字节都表示地址类型,不同的是:- 内部结构
sockaddr
用最后的14个字节来表示sa_data
,sockaddr_in
把最后14个字节拆分成sin_port
,sin_addr
和sin_zero
分别表示端口、IP地址、填充字节sin_zero
填充字节使sockaddr_in
和sockaddr
保持一样大小都是16字节
- 用途
sockaddr
是给操作系统使用的,因为早期的系统调用函数指定了参数类型必须是sockaddr
sockaddr_in
是给程序员使用的,因为sockaddr_in
区分了IP地址和端口,使用更方便
- 内部结构
- 程序员把类型、IP地址、端口填充给
sockaddr_in
结构体,然后强制转换成sockaddr
,作为参数传递给系统调用函数
基本TCP套接字编程
网络通信流程图
![](https://i-blog.csdnimg.cn/blog_migrate/4999afb4fe771f840648900fd6f7bf04.png)
- 上图表示:借助TCP实现一个CS模型所需要的网络套接字函数
- 一个网络通信的建立需要三个套接字:一对用来通信,一个用来监听
socket()
创建一个socket
bind()
为socket
绑定ip+port
listen()
设置监听上限,即同时跟服务器建立socket
连接的数量accept()
:阻塞监听客户端连接,创建一个新的socket
用来与客户端通信connect()
:客户端使用现有的socket与服务器建立连接,如果不使用bind
绑定客户端地址结构,采用“隐式绑定”,系统自动分配ip+port
TCP协议通讯流程
![](https://i-blog.csdnimg.cn/blog_migrate/d4cda0d6460f33176c01cd492260df88.png)
-
连接的建立:三次握手的过程
- 服务器调用
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状态转换
![](https://i-blog.csdnimg.cn/blog_migrate/fb8fe6c7080130dc691700ae8eb87806.png)
- 实线表示主动发起连接一端的状态
- 虚线表示被动接收连接一端的状态
主动发起连接请求端
![](https://i-blog.csdnimg.cn/blog_migrate/edd999d6e4bc6aaf582ffd61e65c6b7c.png)
CLOSED
:表示初始状态SYN_SENT
:主动打开状态,表示客户端已发送SYN
报文。这个状态与SYN_RCVD
遥相呼应,当客户端发送SYN
报文(第一次握手)后,随即进入到SYN_SENT
状态,并等待服务端的发送三次握手中的第2
个报文。ESTABLISHED
:数据传输状态,表示连接已经建立。当客户端收到服务器应答的SYN-ACK
报文(第二次握手),并回复ACK
报文(第三次握手)后,随即进入到ESTABLISHED
状态
主动关闭连接请求端
![](https://i-blog.csdnimg.cn/blog_migrate/02132e01cb53bf764947b10818afb4b2.png)
FIN_WAIT_1
:主动关闭连接的一方,发出FIN
报文后进入该状态FIN_WAIT_2
:半关闭状态,该状态下的socket只能接收数据,不能发送数据。当主动关闭连接的一方,发出FIN
收到ACK
以后进入该状态TIME_WAIT
: 当收到了对方的FIN
报文,并发送出了ACK
报文后,进入到TIME_WAIT
状态,CLOSE
:TIME_WAIT
状态等2MSL
后即可回到CLOSED
初始状态- 如果
FIN_WAIT_1
状态下,收到对方同时带FIN
标志和ACK
标志的报文时,可以直接进入到TIME_WAIT
状态,而无须经过FIN_WAIT_2
状态 - 只有主动关闭连接方会经历该状态
- 如果
2MSL
时长:一定出现在【主动关闭连接请求端】。对应TIME_WAIT
状态,保证最后一个ACK
能成功被对端接收。(等待2MSL
时长期间,对端没收到我发的ACK
,对端会再次发送FIN
请求)
被动接收连接
![](https://i-blog.csdnimg.cn/blog_migrate/3b4e3994617dbcb48ff24d4a1861313a.png)
CLOSED
:表示初始状态LISTEN
:监听状态,表示服务器端可以接受连接请求SYN_RCVD
: 当接收到SYN
报文(第一次握手),并回复SYN-ACK
报文(第二次握手)后,进入SYN_RCVD
状态ESTABLISHED
:数据传输状态,表示连接已经建立。当服务器接收到客户端应答的ACK
报文(第三次握手)后,随即进入到ESTABLISHED
状态
被动关闭连接
![](https://i-blog.csdnimg.cn/blog_migrate/336e86883c32d0d5b40dfa995ea2ecd7.png)
CLOSE_WAIT
:当接收到主动关闭方发送的FIN
并应答一个ACK
报文后,进入到CLOSE_WAIT
状态。接下来查看是否还有数据发送给对方,如果没有,则发送FIN
报文给对方- 说明对端(主动关闭连接端)处于半关闭状态
LAST_ACK
: 当被动关闭一方在发送FIN
报文后,进入LAST_ACK
状态CLOSE
:当被动关闭一方在LAST_ACK
状态接收到对方发送的ACK
后进入CLOSE
初始状态,即关闭连接
socket函数
// 创建一个套接字
int socket(int domain, int type, int protocol);
domain
:IP协议,AF_INET
、AF_INET6
、AF_UNIX/AF_LOCAL
type
:数据传输协议,流式协议SOCK_STREAM
,报式协议SOCK_DGRAM
protocol
:数据传输协议中的代表协议。- 默认
0
,根据type
选择指定协议。 - 流式协议
SOCK_STREAM
代表协议TCP
- 报式协议
SOCK_DGRAM
代表协议UDP
- 默认
- 返回值
- 成功:新套接字对应的文件描述符
- 失败:
-1 errno
,
【例】用socket()
函数创建一个套接字,使用IPv4协议、流式协议,返回该套接字的文件描述符
fd = socket(AF_INET, SOCK_STREAM, 0)
bind函数
// 给socket绑定一个地址结构(ip+port)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd
:套接字对应的文件描述符addr
:(struct sockaddr)&addr
addr
要先初始化
addrlen
:sizeof(addr)
,地址结构的大小- 返回值
- 成功:
0
- 失败:
-1 errno
- 成功:
【例】用bind
函数绑定socket地址结构
// 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函数
// 设置同时与服务器建立连接的上限数(同时进行三次握手的客户端)
int listen(int sockfd, int backlog);
sockfd
:套接字对应的文件描述符backlog
:上限数值,最大值128
- 返回值
- 成功:
0
- 失败:
-1 errno
- 成功:
accept函数
// 阻塞等待客户端建立连接,成功的话,返回一个与客户端成功连接的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函数
// 客户端使用现有的socket与服务器建立连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd
:客户端套接字对应的文件描述符addr
:传入参数。服务器的地址结构addrlen
:服务器的地址结构的大小- 返回值
0
:成功-1 errno
:失败
- 如果不使用
bind
绑定客户端地址结构,采用“隐式绑定”,系统自动分配ip+port
【例】用connect
函数请求连接服务器
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函数
int setsockopt(int sockfd, int level, int optname, const void *optval, ,socklen_t optlen);
- 函数功能:用来设置参数
sockfd
所指定的socket
的状态 level
:代表欲设置的网络层,SOL_SOCKET
:socket
层
optname
:代表欲设置的选项参数SO_REUSEADDR
:地址复用
optval
:代表欲设置的值1
:启用0
:不启用
optlen
:为optval
的长度
多进程并发服务器
使用多进程并发服务器时要注意:
- 防止僵尸进程
- 捕捉SIGCHLD信号
// 多进程并发服务器
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
产生子进程后,子进程和父进程一样,都有listenfd
和connectfd
两个套接字。子进程不需要监听套接字,只需要与客户端通信的套接字,所以关闭listenfd
;父进程不需要与客户端通信的套接字,只需要监听套接字用来监听客户端请求,所以关闭connectfd
-
read()
:读缓冲 -
将读到的字节进行操作
-
write()
:写缓冲 -
signal()
:信号捕捉,如果捕捉到SIGCHLD
信号,说明子进程结束,就调用处理函数,完成子进程回收 -
waitpid
:因为waitpid()
可以设置不阻塞,所以使用waitpid()
。- 由于设置
waitpid()
为非阻塞,所以当子进程没结束时,返回0
,需要再次执行去回收,防止所有子进程都变为僵尸进程,所以需要设置为循环执行waitpid()
,子进程一旦结束,一定要立刻回收。但循环执行的话,父进程可能会阻塞于waitpid()
,此时如果有新的客户端连接请求,父进程无法响应。 - 结局办法:将上述操作放入信号处理函数中
- 由于设置
-
continue
:结束本次循环,父进程监听下一个客户端的连接请求
多路I/O转接服务器
原理:借助内核,select
或poll
来监听客户端连接、数据通信事件
select函数
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
:阻塞,一直等timeval
:timeval
是一个结构体,有两个成员变量:tv_sec
和tv_usec
,分别是秒和微秒>0
:设置成员变量为固定时间0
:不阻塞,检查描述字后立即返回,轮询
-
返回值:
>0
:所有的文件描述符监听集合中满足对应事件的总个数0
:没有满足对应事件的文件描述符-1
:errno
-
优点
- 跨平台:
win
、linux
、macOS
、Unix
、类Unix
、mips
- 跨平台:
-
缺点
- 监听上限受文件描述符限制,最大 1024
- 无法直接定位满足监听事件的文件描述符,只能用循环轮询或自己构建数组保存各个
connfd
// 清空文件描述符集合中(所有位置零)
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);
思路分析:
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监听集合中。
while(1) {
rset = allset; // rset作为传入传出参数
ret = select(lfd+1, &rset, NULL, NULL, NULL); // 监听文件描述符集合对应事件
if(ret > 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函数
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
,即POLLIN
、POLLOUT
、POLLERR
nfds
:监听数组中的实际监听个数timeout
:阻塞监听时间,单位:毫秒-1
:阻塞,一直等0
:不阻塞,检查描述字后立即返回,>0
:等待指定毫秒数
- 返回值:
>0
:所有的文件描述符监听集合中满足对应事件的总个数0
:没有满足对应事件的文件描述符-1
:errno
- 优点
- 自带数组结构,分离了传入传出参数
- 可以拓展监听上限,超出 1024限制
- 缺点
- 不能跨平台,只能UNIX/LINUX,不能WINDOWS
- 无法直接定位满足监听事件的文件描述符,只能用循环轮询或自己构建数组保存各个
connfd
基本UDP套接字编程
TCP和UDP对比
-
TCP:传输控制协议
- 面向连接的,可靠数据包传输。
- 对于不稳定的网络层,采取完全弥补的通信方式——丢包重传
- 优点:
- 数据流量稳定:在建立TCP连接后,数据传输流量是稳定的
- 传输速度稳定:在建立TCP连接后,数据传输速度是稳定的
- 传输顺序一致:在网络环境不发生变化的情况下,各个数据包经过的路由节点一致,数据报到达顺序与发送时一致
- 缺点:
- 传输速度慢
- 传输效率低
- 资源开销大
- 使用场景
- 数据的完整型要求较高,不追求效率
- 大数据传输、文件传输。
- 数据的完整型要求较高,不追求效率
-
UDP:用户数据报协议
- 无连接的,不可靠的数据报传递
- 对于不稳定的网络层,采取完全不弥补的通信方式——默认还原网络状况
- 优点:
- 传输速度快
- 传输速率高
- 资源开销小
- 缺点:
- 数据流量不稳定
- 传输速度不稳定
- 传输顺序不一致:各个数据报经过的路由节点可能不一致,数据报到达顺序可能与发送时不一致
- 使用场景
- 对时效性要求较高场合,稳定性其次
- 游戏、视频会议、视频电话
- 对时效性要求较高场合,稳定性其次
- 可以通过采用应用层数据校验协议,弥补udp的不足,保证数据包有效传递
UDP通信流程
//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();
![](https://i-blog.csdnimg.cn/blog_migrate/1600dd87e7fec4beb115d195ff0a2067.png)
- 因为不用三次握手,所以服务器不需要
accept()
,客户端不需要connect()
- 因为
listen()
是用来设置服务器同时与客户端进行三次握手的上限,所以listen()
可有可无 - 由于
listenfd
不负责通信,所以read()
被替换为recvfrom()
,write()
被替换为sendto()
- 客户端直接使用
sendto()
向服务器发起连接
recvfrom函数
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
:对端关闭连接-1
:errno
sendto函数
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
:对端关闭连接-1
:errno
Linux和C语言知识
包裹函数
- 包裹函数用于错误处理
- 任何现实世界的程序都必须检查每个函数调用是否返回错误
- 每个包裹函数完成实际的函数调用,检查返回值,并在发生错误时终止进程
- 约定包裹函数名是实际函数名的首字母大写形式
Unix errno值
- 只要一个Unix函数中有错误发生,全局变量
errno
就被置为一个指明该错误类型的正值,函数本身则通常返回-1
err_sys
查看errno
变量的值并输出相应的出错消息后退出进程- 例如:当
error
的值等于ETIMEDOUT
时,将输出Connection timed out
- 例如:当
error
的值只在函数发生错误时设置,如果函数不返回错误,error
的值就没有定义error
的所有正数错误值都是常值,具有以E
开头的全大写字母名字,值0
不表示任何错误
sizeof操作符
sizeof
是C
语言的一种单目操作符,如C
语言的其他操作符++
、--
等sizeof
操作符以字节形式给出了其操作数的存储大小- 操作数可以是一个表达式或括在括号内的类型名。操作数的存储大小由操作数的类型决定
文件描述符
文件描述符是Linux
编程中的一个术语。当一个文件打开后,系统会分配一部分资源来保存该文件的信息,以后对文件的操作就可以直接引用该部分资源了。文件描述符可以认为是该部分资源的一个索引,在打开文件时返回。
当程序执行时,就已经有三个文件流打开了,它们分别是标准输入stdin
,标准输出stdout
和标准错误输出stderr
。和流式文件相对应的是,也有三个文件描述符被预先打开,它们分别是STDIN_FILENO
:0
,STDOUT_FILENO
:1
,STDERR_ FILENO
:2
,代表标准输入、标准输出和标准错误输出。
需要指出的是,上面的流式文件输入、输出和文件描述符的输入输出方式不能混用,否则会造成混乱
fileno函数
int fileno(FILE *stream);
- 函数功能:把文件流指针转换成文件描述符
- 返回值:
- 指定文件流的文件描述符:成功
-1
:失败
fputs函数
int fputs(const char *str, FILE *stream)
- 函数功能:把字符串写入到指定的流
stream
中,但不包括空字符 str
:要写入的字符数组,以空字符终止stream
:要被写入字符串的流,是指向FILE
对象的指针- 返回值:
- 非负值:成功
EOF
:发生错误
fgets函数
char *fgets(char *str, int n, FILE *stream)
- 函数功能:从指定的流
stream
读取一行,并把它存储在str
所指向的字符串内。当读取(n-1)
个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定 str
:存储读取的字符的字符数组。n
:要读取的最大字符数(包括最后的空字符)stream
:指向FILE
对象的指针,标识了要从中读取字符的流- 返回值:
str
参数:成功NULL
:- 到达文件末尾或者没有读取到任何字符,
str
的内容保持不变,并返回一个NULL
- 发生错误
- 到达文件末尾或者没有读取到任何字符,
bzero函数
void bzero(void *s, int n);
- 函数功能:将内存块或字符串的前
n
个字节清零 s
:内存或字符串指针n
:需要清零的字节数
头文件
#include <>
格式:引用标准库头文件,编译器从标准库目录开始搜索#include""
格式:引用非标准库的头文件,编译器从用户的工作目录开始搜索
命令行参数
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]);
}
argc
:int
类型,表示命令行参数的个数- 不允许用户传递,自动根据用户从命令行输入的参数个数确定
argv
:char**
类型,用于存储用户从命令行传递进来的参数- 它的第一个成员是用户运行的程序名字
read和write函数
read函数
ssize_t read(int fd, void *buf, size_t count)
- 作用:从文件描述符相关联的文件里读入
count
个字节的数据,并把它们放到数据区buf
中。 - 返回值:实际读入的字节数,这可能会小于请求的字节数,
0
,已经读到套接字末尾(对端已经关闭)-1
,表示read()
调用出现了错误,错误代码保存在全局变量errno
中EINTR
:被异常中断,需要重启EAGIN/EWOULDBLOCK
:以非阻塞方式读数据,但没有数据,需要再次读ECONNRESET
:连接被重置,需要close()
,移出监听队列
>0
,表示读取的字节数
write函数
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()
的封装
// 从文件描述符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函数
int close(int sockfd); //返回值:成功为0,出错为-1.
close
一个套接字的默认行为是把套接字标记为已关闭,然后立即返回到调用进程,该套接字描述符不能再由调用进程使用,也就是说它不能再作为read()
或write()
的第一个参数- 然而,TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP连接终止序列。
- 在多进程并发服务器中,父子进程共享着套接字,套接字描述符引用计数记录着共享着的进程个数,当父进程或某一子进程
close
掉套接字时,描述符引用计数会相应的减一,当引用计数仍大于零时,这个close
调用就不会引发TCP的四路握手断连过程
shutdown函数
int shutdown(int sockfd,int howto); //返回值:成功为0,出错为-1.
该函数的行为依赖于howto
的值
SHUT_RD
:值为0,关闭连接的读这一半。SHUT_WR
:值为1,关闭连接的写这一半。SHUT_RDWR
:值为2,连接的读和写都关闭。
终止网络连接的通用方法是调用close()
函数。但使用shutdown()
能更好的控制断连过程(使用第二个参数)
close和shutdown区别
![](https://i-blog.csdnimg.cn/blog_migrate/7decf42257bf433dad6f62c6bbccc783.png)
-
使用
close
中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为0
时才关闭连接。 -
shutdown
不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。
注意:-
如果有多个进程共享一个套接字,
close
每被调用一次,计数减1
,直到计数为0
时,也就是所有进程都调用了close
,套接字将被释放。 -
在多进程中如果一个进程调用了
shutdown(sockfd, SHUT_RDWR)
后,其它的进程将无法进行通信。但,如果一个进程close(sockfd)
将不会影响到其它进程。
-
-
shutdown
在关闭多个文件描述符引用的文件时,采用全关闭方法;close
只关闭一个
sleep函数
sleep(time)
函数以秒为单位,将进程由运行态转入阻塞态,使进程睡眠;当睡眠时间结束,进程由阻塞态转为就绪态,等待CPU空闲后进入运行态继续执行剩下的指令- 这个函数是可以被中断的
- 当进程在睡眠的过程中,如果被中断,那么当中断结束,回来再执行该进程的时候,该进程会从
sleep()
函数的下一条语句执行;这样的话就不会睡眠time
秒了
- 当进程在睡眠的过程中,如果被中断,那么当中断结束,回来再执行该进程的时候,该进程会从
进程控制
fork函数
pid_t fork(void)
-
函数功能:创建子进程。
-
原理分析:执行
fork()
后,由父进程复制产生子进程,两个进程都由fork()
之后的代码继续向下执行,父进程返回子进程的pid
,子进程返回0
- 当返回值是
0
,说明这是子进程;当返回值>0
,也就是子进程的pid
,说明这是父进程; - 父子进程相同:
data
段、code
段、堆、栈、环境变量、全局变量、宿主目录位置、进程工作目录位置、信号处理方式 - 父子进程不同:
pid
、fork
返回值、各自的父进程、进程创建时间、闹钟、未决信号集 - 父子进程共享: 读时共享、写时复制。读的时候不复制,父子进程用同一份数据;写的时候需要一份复制后写,写后不影响另一进程
- 当返回值是
-
返回值:
- 成功:父子进程各自返回,父进程返回子进程的
pid
,子进程返回0
- 失败:
-1
errno
- 成功:父子进程各自返回,父进程返回子进程的
exec函数族
exec
函数族中的函数可以使fork()
出的子进程不执行复制来的父进程的代码,转而执行另一个程序,子进程的代码和数据完全被新程序替换
回收子进程
孤儿进程
父进程先于子进程终止,子进程成为孤儿进程,会被 init
进程领养
僵尸进程
进程终止,父进程存在但尚未回收该子进程,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程
僵尸进程无法使用kill
命令清除,因为kill
命令只是用来终止进程的,而僵尸进程已经终止
wait函数
一个进程终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的 PCB 还保留着。内核在PCB保存了一些信息:
- 如果是正常终止则保存着退出状态,
- 如果是异常终止则保存着导致该进程终止的信号
这个进程的父进程可以调用
wait()
或者waitpid()
获取PCB中保存的信息,然后彻底清除掉这个进程
pid_t wait(int *status);
- 函数功能:父进程调用
wait()
函数可以回收子进程,该函数具体有以下两个功能- 阻塞等待子进程退出:
wait()
函数一直阻塞等待子进程的结束;子进程结束后,回收该子进程,wait()
函数返回 - 获取子进程残留资源:回收PCB中记录的子进程的退出状态和子进程结束的原因
- 阻塞等待子进程退出:
status
:传出参数,保存子进程残留资源NULL
:不保存子进程残留资源
- 返回值:
- 成功:子进程ID
- 失败:
-1
waitpid函数
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
:失败0
:options
指定为WNOHANG
,且子进程未结束
进程通信
进程间通信的常用方式:
- 管道:使用最简单
- 信号:占用CPU和内存的开销最小,携带数据量有限,表示的数据单一,应用在对速度要求高的进程之间
- 共享映射区:应用在无血缘关系的进程之间
- 本地套接字:最稳定
信号的概念
- 信号是软件层面上的“中断”。
- 一旦信号产生,无论程序执行到什么位置,必须立即停止运行转而处理信号,待信号处理结束再继续执行后续指令
- 所有信号的产生及处理全部都是由内核完成的
- 每个进程收到的所有信号,都是由内核负责发送的,内核处理
信号的产生
产生信号的方式:
- 按键产生
- 系统调用产生
- 软件条件产生
- 硬件异常产生
- 命令产生
信号集的概念
- 递达:产生并且送达到进程。直接被内核处理掉。
- 未决:产生与递达之间的状态。
- 信号处理方式:
- 默认处理动作
- 忽略
- 捕捉(自定义处理动作)
- 阻塞信号集(信号屏蔽字): 本质是位图。用来记录信号的屏蔽状态。一旦被屏蔽的信号,在解除屏蔽前,一直处于未决状态
- 未决信号集:本质是位图。用来记录信号的处理状态。该信号集中的信号,
1
表示已经产生,但尚未被处理现处于未决状态;0
表示处理完成 - 信号集存储在PCB中
信号的四要素
-
使用
kill -l
命令可以查看当前系统可使用的信号- 不存在编号为0的信号
- 1-31号信号是常规信号(普通信号/标准信号)
- 34-64号信号是实时信号,与驱动编程和硬件相关
-
信号的四要素
- 信号编号
- 信号名称
- 信号对应事件
- 信号默认处理动作
- Term:终止进程
- Ign:忽略信号
- Core:终止进程,生成Core文件
- Stop:停止(暂停)进程
- Cont:继续运行进程
-
只有信号对应的事件发生了,该信号才会被递送(但不一定递达)
信号捕捉
信号捕捉:捕捉到信号后,自定义处理动作
signal函数
注册一个信号捕捉函数
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
会话是多个进程组的集合,进程组是多个进程的集合