参考文章:

1.socket编程基础知识

2.socket编程UDP程序

3.socket编程TCP程序

4.unix domain socket 编程

5.Unix Domain Socket 实现原理

6.Linux C Socket UDP编程详解及实例分享

7.Linux编程之UDP SOCKET全攻略

8.udp 超时设置(select函数的一种用法)

前言

  • socket通信本质上就是两个进程间的通信(跨网络的进程间通信)

  • IP地址能够唯一标识公网内的一台主机,而端口号能够唯一标识一台主机上的一个进程,因此IP地址+端口号就能够唯一标识网络上的某一台主机的某一个进程

  • 或者这样说:IP地址就像是某个快递点的位置,端口号就是其中快递包裹的编号,而进程是收或寄快递的人

  • TCP协议:面向连接、可靠、基于字节流的传输层通信协议

  • UDP协议:无需建立连接的、不可靠的、面向数据报的传输层通信协议

  • 计算机存储策略:

    • 大端模式:数据高字节保存到内存低字节,数据低字节保存到内存高字节(大对小,小对大/低地址高字节) -> 网络数据流采用大端模式
    • 小端模式:数据高字节保存到内存高字节,数据低字节保存到内存低字节(大对大,小对小/低地址低字节)
  • 网络字节序和主机字节序之间的转换函数:

1
2
3
4
5
6
7
#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong); //主机字节序转换为网络字节序 32位
uint16_t htons(uint16_t hostshort); //主机字节序转换为网络字节序 16位
uint32_t ntohl(uint32_t netlong); //网络字节序转换为主机字节序 32位
uint16_t ntohs(uint16_t netshort); //网络字节序转换为主机字节序 16位
// h: host n: network
  • sockaddr结构(跨网络通信:sockaddr_in和本地通信:sockaddr_un):统一套接字的网络通信和本地通信,使之能使用同一套函数;设置参数时通过设置协议家族(16位地址类型)这个字段,来表明我们是要进行网络通信还是本地通信;实际进行网络通信时,定义的还是sockaddr_in结构体,在传参时将该结构体的地址类型强制转换为sockaddr*
  • UDP通信流程框架图
    • server(服务器)接收请求步骤:socket()->bind()->recvfrom()
    • client(客户端)发送请求步骤:socket()->sendto()
    • 实际上UDP没有严格区分server端和client端,唯一的区别是绑不绑定(bind)端口

1093303-20170115195937619-2089905370

套接字相关函数

1.socket()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<sys/types.h>
#include<sys/socket.h>

函数原型:
int socket(int domain, int type, int protocol);
作用: 建立套接字文件描述符
参数:
(1) domain(以下列出常用网络通信域)
AF_INET: 常用协议,使用TCP或UDP传输(用于IPV4)
AF_INET6: 同上,区别在于是用于IPV6
AF_UNIX: 本地协议,用于Unix和LInux系统(是同一台主机内进程间通信的机制,使用文件系统路径名作为地址来建立连接,比传统的网络Socket更加高效)
(2) type
SOCK_STREAM: TCP传输
SOCK_DGRAM: UDP传输
SOCK_RAW: 原始网络访问
(3) protocol
传0即可,使用默认协议
返回值:
成功: 返回指向新创建的socket的文件描述符
失败: 返回-1,设置errno

2.bind()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<sys/types.h>
#include<sys/socket.h>

函数原型:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
作用: 将套接字文件描述符和一个地址类型变量进行绑定
参数:
(1) sockfd
socket文件描述符(监听端口的套接字文件描述符),即调用socket()函数返回的文件描述符
(2) addr
与网络相关的属性信息,包括协议家族、ip地址和端口号等(需要绑定的ip和端口)
(3) addrlen
addr结构体的长度
返回值:
成功: 返回0
失败: 返回-1,设置errno
备注:
使用socket()创建完套接字后只是在系统层面打开了一个文件,但并没有将该文件与网络关联起来,需要调用bind()进行文件和网络的绑定和关联
服务器需要绑定监听的网络地址和端口号,而客服端不需要
INADDR_ANY表示绑定

3.recvfrom()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<sys/types.h>
#include<sys/socket.h>

函数原型:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
作用: 通过套接字接收数据
参数:
(1) sockfd
socket文件描述符(监听端口的套接字文件描述符),即调用socket()函数返回的文件描述符
(2) buf
接收的数据存放的缓冲区
(3) len
接收数据字节数
(4) flags
接收方式,一般设置为0,表示阻塞接收
(5) src_addr
与对端网络相关的属性信息,包括协议家族、ip地址和端口号等
指向发送数据的主机地址信息的结构体,即可以从该参数获取到数据是谁发出的
(6) addrlen
src_addr结构体长度
返回值:
成功: 返回实际接收的字节数
失败: 返回-1,设置errno

4.sendto()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<sys/types.h>
#include<sys/socket.h>

函数原型:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
作用: 通过套接字发送数据
参数:
(1) sockfd
socket文件描述符(监听端口的套接字文件描述符),即调用socket()函数返回的文件描述符
(2) buf
待发送数据存放的缓冲区
(3) len
待发送数据字节数
(4) flags
发送方式,一般设置为0,表示阻塞发送
(5) dest_addr
与对端网络相关的属性信息,包括协议家族、ip地址和端口号等
指向接收数据的主机地址信息的结构体,即指定数据要发送到哪个主机的哪个进程
(6) addrlen
dest_addr结构体长度
返回值:
成功: 返回实际发送的字节数
失败: 返回-1,设置errno

5.close()

1
2
3
4
5
6
7
8
9
10
11
#include<unistd.h>

函数原型:
int close(int fd);
作用: 关闭创建的socket
参数:
fd
socket()函数返回的fd
返回值:
成功: 返回0
失败: 返回-1,设置errno

6.inet_addr()/inet_ntoa()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
函数原型:
in_addr_t inet_addr(const char *cp);
类似于inet_aton()函数
参数:
cp: 待转换字符串ip
返回值:
转换后的整数ip

函数原型:
char *inet_ntoa(struct in_addr in);
参数:
in: 待转换整数ip
返回值:
转换后的字符串ip

7.connect()

udp套接字也可以使用,因而udp套接字可以分为已连接的udp套接字和未连接的udp套接字

已连接的udp套接字,必须先经过connect()来向目标服务器进行指定,然后调用read/write进行信息的收发,目标主机的IP和端口是在connect()时确定的,也就是说,一旦conenct()成功,我们就只能收发该主机的信息

当知道目的地址和端口时,采用连接的udp套接字效率更高

1093303-20170115202743119-1345209901

参考: socket编程TCP程序

Socket Programming in C/C++

StatediagramforserverandclientmodelofSocketdrawio2-448x660

1.listen()

1
2
3
4
5
6
7
8
9
10
11
函数原型:
int listen(int sockfd, int backlog);
作用: It puts the server socket in a passive mode, where it waits for the client to approach the server to make a connection.
参数:
(1) sockfd
需要监听套接字对应的文件描述符
(2) backlog
全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列(超出的连接请求会被忽略),该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可
返回值:
成功: 返回0
失败: 返回-1,设置errno

2.accept()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
函数原型:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
作用: It extracts the first connection request on the queue of pending connections for the listening socket, sockfd, creates a new connected socket, and returns a new file descriptor referring to that socket. At this point, the connection is established between client and server, and they are ready to transfer data.
参数:
(1) sockfd
需要监听套接字对应的文件描述符(监听套接字)
(2) addr
对端网络相关属性信息,包括协议家族、ip和端口等
(3) addrlen
addr结构体长度,返回实际读取到的addr结构体长度(输入输出型参数)
返回值:
成功: 返回一个新的socket文件描述符(服务套接字),用于和客户端通信
失败: 返回-1,设置errno
1.监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
2.accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字

3.connect()

1
2
3
4
5
6
7
8
9
10
11
12
13
函数原型:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
作用: The connect() system call connects the socket referred to by the file descriptor sockfd to the address specified by addr. Server's address and port is specified in addr.
参数:
(1) sockfd
需要连接套接字对应的文件描述符(通过该套接字发起连接请求)
(2) addr
对端网络相关属性信息,包括协议家族、ip和端口等
(3) addrlen
addr结构体长度
返回值:
成功: 连接成功
失败: 返回-1,设置errno

4.read()

1
2
3
4
5
6
7
8
9
10
11
12
13
函数原型:
ssize_t read(int fd, void *buf, size_t count);
作用: 从连接的套接字中读取数据
参数:
(1) fd
对应套接字的文件描述符
(2) buf
读取到的数据存储位置
(3) count
读取数据字节数
返回值:
成功: 实际读取的字节数
失败: 返回0(文件结束/对端关闭连接)或-1(读取错误),设置errno

5.write()

1
2
3
4
5
6
7
8
9
10
11
12
13
函数原型:
ssize_t write(int fd, const void *buf, size_t count);
作用: 从连接的套接字中读取数据
参数:
(1) fd
对应套接字的文件描述符
(2) buf
需要写入数据的位置
(3) count
写入数据字节数
返回值:
成功: 实际写入的字节数
失败: 返回-1,设置errno

6.setsockopt()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <sys/socket.h>
函数原型:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
作用:
This helps in manipulating options for the socket referred by the file descriptor sockfd. This is completely optional, but it helps in reuse of address and port. Prevents error such as: "address already in use"
参数:
(1) sockfd
套接字描述符
(2) level
设置选项的级别,如果想要在套接字级别上设置选项,就必须把level设置为 SOL_SOCKET
(3) optname
需要设置的选项
(4) optval
指向存放选项值的缓冲区
(5) optlen
optval 缓冲区的长度
返回值:
成功: 返回0
失败: 返回-1, 设置errno

参考:setsockopt函数功能及参数详解

  • 阻塞接收时避免永久阻塞

传统的recvfrom是阻塞进行的,即调用recvfrom之后程序就会阻塞,等待数据包的到来,如果没有数据包,程序就永远等待

解决方法:给客户端调用的recvfrom()函数设置超时处理,超时之后没有接收到数据就直接返回

(1)select()函数+recvfrom()函数

对于select()函数,可参考: select函数及fd_set介绍

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
27
28
29
int main()
{
...
struct timeval tv;
fd_set readfds;
unsigned int n=0;
unsigned int buf_n=1024;
while(1)
{
FD_ZERO(&readfds); // 清空
FD_SET(fd,&readfds); // 置位fd,将需要处理的sock加入到上一步清空后的集合中
// 设置超时参数 sec和usec
tv.tv_sec=sec;
tv.tv_usec=usec;
// 以非阻塞方式调用select()函数,设置时间内有数据则返回并设置readfds中fd对应位为1,否则返回并设置readfds中对应位为0
select(fd+1,&readfds,NULL,NULL,&tv);
// 测试readfds中fd对应位有没有置1,如果置1则返回成功,否则返回失败
if(FD_ISSET(fd,&readfds))
{
if((n=recvfrom(fd,buf,buf_n,0,&addr,&len))>=0)
{
// 成功接收处理代码
}
}
else
// 超时处理代码
}
return 0;
}

注意:

  • 如果设定时间内没有数据到来还想继续等待N次,那么一定要注意重新设置readfds,因为它已经被select破坏了,如果不重新设置的话,你的select语句会返回-1,strerr时会打印出参数设置出错,主要是由于readfds中全部为零,select不知道该去监视哪个sock

  • 重复等待时不只是要重新设置readfds,同时还要重新设置一下tv的值,因为select同时也破坏了tv的值(select在返回时会改变tv,改变的公式是tv=tv-等待的时间,所以如果tv时间内没有数据到达的话,select返回时tv会变成0)

(2)UDP服务recvfrom函数设置非阻塞

(3)Linux Socket 网络编程 阻塞与非阻塞 断线重连机制

(4)使用信号中断处理超时

Linux系统

Linux系统下关于使用socket进行UDP和TCP通信的代码已放到如下链接中:

https://github.com/Thee24LYJ/LearningCode/tree/main/tcp%26udp%20socket%20code

该文件夹下子文件夹对应的代码解释如下:

  • udp_test -> udp通信
  • tcp_test -> tcp通信
  • tcp_test_SIGCHLD -> tcp通信(多进程之捕捉SIGCHLD信号)
  • tcp_test_son -> tcp通信(多进程之孙子进程)
  • tcp_test_thread -> tcp通信(多线程)
  • tcp_test_threadpool -> tcp通信(线程池)

一、UDP

二、TCP

单进程版本:一个server同一时刻只能响应一个client的请求

多进程的tcp示例

  • 子进程继承父进程的文件描述符表: 即父进程打开的文件描述符是3,此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件,而如果子进程再创建一个子进程,那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件

  • 子进程等待: 父进程创建子进程后,需要等待子进程退出(否则子进程会变成僵尸进程)

    阻塞等待/非阻塞等待

    不等待子进程退出:

    • 捕捉SIGCHLD信号,将其处理动作设置为忽略。
    • 让祖父进程创建父进程,父进程再创建孙子进程,最后让孙子进程为客户端提供服务(孙子进程由系统回收)(不推荐)

多线程的tcp示例

  • 当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。主线程(服务进程)创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程(服务进程)就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端。
  • 文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表。而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表。
  • 当服务进程(主线程)调用accept函数获取到一个文件描述符后,其他创建的新线程是能够直接访问这个文件描述符的。虽然新线程能够直接访问主线程accept上来的文件描述符,但此时新线程并不知道它所服务的客户端对应的是哪一个文件描述符,因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符的值,也就是告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作
  • 由于代码当中用到了多线程,因此编译时需要携带上 -l pthread 选项。此外,由于我们现在要监测的是一个个的线程,因此在监控时使用的不再是ps -axj 命令,而是 ps -aL 命令。
  • 当一个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体当中提取出对应的参数,然后调用Service函数为该客户端提供服务