套接字(socket)是 Linux 下的一种进程间通信机制(socket IPC),在前面的内容中已经给大家提到过,使用 socket IPC 可以使得在不同主机上的应用程序之间进行通信(网络通信),当然也可以是同一台主机上的不同应用程序。socket IPC 通常使用客户端<--->服务器这种模式完成通信,多个客户端可以同时连接到服务器中,与服务器之间完成数据交互。
内核向应用层提供了 socket 接口,对于应用程序开发人员来说,我们只需要调用 socket 接口开发自己的应用程序即可!socket 是应用层与 TCP/IP 协议通信的中间软件抽象层,它是一组接口。在设计模式中,socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议隐藏在 socket 接口后面,对用户来说,一组简单的接口就是全部,让 socket 去组织数据,以符合指定的协议。所以,我们无需深入的去理解 tcp/udp 等各种复杂的 TCP/IP 协议,socket 已经为我们封装好了,我们只需要遵循 socket 的规定去编程,写出的程序自然遵循 tcp/udp 标准的。
当前网络中的主流程序设计都是使用 socket 进行编程的,因为它简单易用,它还是一个标准(BSD socket),能在不同平台很方便移植,比如你的一个应用程序是基于 socket 接口编写的,那么它可以移植到任何实现 BSD socket 标准的平台,譬如 LwIP,它兼容 BSD Socket;又譬如 Windows,它也实现了一套基于socket 的套接字接口,更甚至在国产操作系统中,如 RT-Thread,它也实现了 BSD socket 标准的 socket 接口。
BSD套接字是最早在1983年随着BSD操作系统发布的套接字接口的名称。后来,这个接口被整合到了POSIX规范中,并增加了一些更详细的操作规范。除了术语不同以外,可以认为没有任何区别。BSD套接字涵盖了随着BSD操作系统发布的API,而POSIX标准适用于任何希望符合POSIX的操作系统。
可参考:
具体了解该套接字,可参考:
伯克利套接字(BSD Socket)-CSDN博客
建立连接相关API
socket()函数
socket()函数原型如下所示:
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int socket(int domain, int type, int protocol);
socket()函数类似于 open()函数,它用于创建一个网络通信端点(打开一个网络通信),如果成功则返回一个网络文件描述符,通常把这个文件描述符称为 socket 描述符(socket descriptor),这个 socket 描述符跟文件描述符一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
该函数包括 3 个参数,如下所示:
domain
参数 domain 用于指定一个通信域;这将选择将用于通信的协议族。可选的协议族如下表所示:
对于 TCP/IP 协议来说,通常选择 AF_INET 就可以了,当然如果你的 IP 协议的版本支持 IPv6,那么可以选择 AF_INET6。
type
参数 type 指定套接字的类型,当前支持的类型有:
protocol
即协议类别,表示为给定的通信域和套接字类型选择默认协议,一般设置为0即可,因为该函数会通过前两个参数自动推导出第三个参数的协议类别。
调用 socket()与调用 open()函数很类似,调用成功情况下,均会返回用于文件 I/O 的文件描述符,只不过对于 socket()来说,其返回的文件描述符一般称为 socket 描述符。当不再需要该文件描述符时,可调用close()函数来关闭套接字,释放相应的资源。
如果 socket()函数调用失败,则会返回-1,并且会设置 errno 变量以指示错误类型。
使用示例
int socket_fd = socket(AF_INET, SOCK_STREAM, 0);//打开套接字 if (0 > socket_fd) { perror("socket error"); exit(-1); } ...... ...... close(socket_fd); //关闭套接字
注意,初始时,客户端和服务器都需要调用socket,来指定使用的协议族和socket类型。
bind()函数
bind()函数原型如下所示:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind()函数用于将一个 IP 地址和端口号与一个套接字进行绑定,对于客户端来说,它与服务器进行通信,首先需要知道服务器的 IP 地址以及对应的端口号,通常服务器的 IP 地址以及端口号都是众所周知的。
调用 bind()函数将参数 sockfd 指定的套接字与一个地址 addr 进行绑定,成功返回 0,失败情况下返回-1,并设置 errno 以提示错误原因。
参数 addr 是一个指针,指向一个 struct sockaddr 类型变量,如下所示:
struct sockaddr { sa_family_t sa_family; char sa_data[14]; }
第二个成员 sa_data 是一个 char 类型数组,一共 14 个字节,在这 14 个字节中就包括了 IP 地址、端口号等信息,这个结构对用户并不友好,它把这些信息都封装在了 sa_data 数组中,这样使得用户不方便对sa_data 数组进行赋值。事实上,这是一个通用的 socket 地址结构体。
一般我们在使用的时候都会使用 struct sockaddr_in 结构体,sockaddr_in 和 sockaddr 是并列的结构(占用的空间是一样的),指向 sockaddr_in 的结构体的指针也可以指向 sockadd 的结构体,并代替它,而且sockaddr_in 结构对用户将更加友好,在使用的时候进行类型转换就可以了。该结构体内容如下所示:
struct sockaddr_in { sa_family_t sin_family; /* 协议族 */ in_port_t sin_port; /* 端口号 */ struct in_addr sin_addr; /* IP 地址 */ unsigned char sin_zero[8]; };
这个结构体的第一个字段是与 sockaddr 结构体是一致的,而剩下的字段就是 sa_data 数组连续的 14 字节信息里面的内容,只不过从新定义了成员变量而已,sin_port 字段是我们需要填写的端口号信息,sin_addr字段是我们需要填写的 IP 地址信息,剩下 sin_zero 区域的 8 字节保留未用。
bind函数的最后一个参数 addrlen 指定了 addr 所指向的结构体对应的字节长度。
使用示例
struct sockaddr_in socket_addr; memset(&socket_addr, 0x0, sizeof(socket_addr)); //清零 //填充变量 socket_addr.sin_family = AF_INET; socket_addr.sin_addr.s_addr = htonl(INADDR_ANY); socket_addr.sin_port = htons(5555); //将地址与套接字进行关联、绑定 bind(socket_fd, (struct sockaddr *)&socket_addr, sizeof(socket_addr));
注意,代码中的 htons 和 htonl 并不是函数,只是一个宏定义,主要的作用在于为了避免大小端的问题,需要这些宏需要在我们的应用程序代码中包含头文件<netinet/in.h>。
说明:
ip用来确定一台设备,端口号用来确定一个进程。
bind函数是在服务端使用,并且绑定的是服务器本身的ip和端口号。
为啥不是绑定对方的ip和端口?比如客户端绑定服务器的,服务器绑定客户端的,可根据使用情况来看,只有服务端需要用bind,并且绑定的是自己的ip和端口,好像客户端的ip和端口不用显式绑定,为啥?
所以,为什么TCP服务端需要调用bind函数而客户端通常不需要呢?
bind是为了将当前的socket(可以理解成网络连接实例)绑定到了服务端的ip和端口上,然后服务端就可以实时监听有没有客户端连接到了这个socket上,也就是说,一旦绑定的这个ip和端口有动静,服务端就可以响应了。
客户端上线是主动向服务器发出请求的,因为服务器已经绑定了IP和端口,所以客户端上线的就向这个IP和端口发出请求,这时因为客户开始发数据了(发上线请求),操作系统就给客户端分配一个随机端口,这个端口和客户端的IP会随着上线请求一起发给服务器。
服务器收到上线请求后就可以从中获取发此请求的客户的IP(源ip字段)和端口,接下来服务器就可以利用获取的IP和端口给客户端回应消息了。
总之一句话:客户端是主动连接(Connect),而服务器是等待接受连接(Accept)
注意:一旦建立了连接,客户端 和 服务器端 均可以发送(Send)或者接收(Receive)数据。
更多参考:
为什么TCP服务端需要调用bind函数而客户端通常不需要呢?_tcp bind-CSDN博客
Tips:bind()函数并不是总是需要调用的,只有用户进程想与一个具体的 IP 地址或端口号相关联的时候才需要调用这个函数。如果用户进程没有这个必要,那么程序可以依赖内核的自动的选址机制来完成自动地址选择,通常在客户端应用程序中会这样做。
难怪客户端可以使用动态IP地址,原来是客户端可以让操作系统来自动获取ip和端口。
tcp服务端必须有bind, 客户端通常不用bind, 当然如果你够无聊, 那也可以用一下bind。在这里, 我要说一下了: 客户端用bind的程序很容易出问题, 你想想啊, 操作系统指定的不会冲突的随机端口难道不比你自己指定的容易冲突的固定端口好?在很多场景下, 我们要在一个pc上开启多个客户端进程, 如果指定固定端口,必然会造成端口冲突,影响通信!所以,我们就不要费力不讨好了,客户端就不要指定端口了,让操作系统来搞。
listen()函数
listen()函数只能在服务器进程中使用,让服务器进程进入监听状态,等待客户端的连接请求,listen()函数在一般在 bind()函数之后调用,在 accept()函数之前调用,它的函数原型是:
int listen(int sockfd, int backlog);
无法在一个已经连接的套接字(即已经成功执行 connect()的套接字或由 accept()调用返回的套接字)上执行 listen()。
参数 backlog 用来描述 sockfd 的等待连接队列能够达到的最大值。在服务器进程正处理客户端连接请求的时候,可能还存在其它的客户端请求建立连接,因为 TCP 连接是一个过程,由于同时尝试连接的用户过多,使得服务器进程无法快速地完成所有的连接请求,那怎么办呢?直接丢掉其他客户端的连接肯定不是一个很好的解决方法。因此内核会在自己的进程空间里维护一个队列,这些连接请求就会被放入一个队列中,服务器进程会按照先来后到的顺序去处理这些连接请求,这样的一个队列内核不可能让其任意大,所以必须有一个大小的上限,这个 backlog 参数告诉内核使用这个数值作为队列的上限。而当一个客户端的连接请求到达并且该队列为满时,客户端可能会收到一个表示连接失败的错误,本次请求会被丢弃不作处理。 一般设置为5、10。
accept()函数
服务器调用 listen()函数之后,就会进入到监听状态,等待客户端的连接请求,使用 accept()函数获取客户端的连接请求并建立连接。函数原型如下所示:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
为了能够正常让客户端能正常连接到服务器,服务器必须遵循以下处理流程:
①、调用 socket()函数打开套接字;
②、调用 bind()函数将套接字与一个端口号以及 IP 地址进行绑定;
③、调用 listen()函数让服务器进程进入监听状态,监听客户端的连接请求;
④、调用 accept()函数处理到来的连接请求。
accept()函数通常只用于服务器应用程序中,如果调用 accept()函数时,并没有客户端请求连接(等待连接队列中也没有等待连接的请求),此时 accept()会进入阻塞状态,直到有客户端连接请求到达为止。当有客户端连接请求到达时,accept()函数与远程客户端之间建立连接,accept()函数返回一个新的套接字。这个套接字与 socket()函数返回的套接字并不同,socket()函数返回的是服务器的套接字(以服务器为例),而accept()函数返回的套接字连接到调用 connect()的客户端,服务器通过该套接字与客户端进行数据交互,譬如向客户端发送数据、或从客户端接收数据。
所以,理解 accept()函数的关键点在于它会创建一个新的套接字,其实这个新的套接字就是与执行connect()(客户端调用 connect()向服务器发起连接请求)的客户端之间建立了连接,这个套接字代表了服务器与客户端的一个连接。如果 accept()函数执行出错,将会返回-1,并会设置 errno 以指示错误原因。
参数 addr 是一个传出参数,参数 addr 用来返回已连接的客户端的 IP 地址与端口号等这些信息。参数addrlen 应设置为 addr 所指向的对象的字节长度,如果我们对客户端的 IP 地址与端口号这些信息不感兴趣,可以把 arrd 和 addrlen 均置为空指针 NULL。
connect()函数
connect()函数原型如下所示:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
该函数用于客户端应用程序中,客户端调用 connect()函数将套接字 sockfd 与远程服务器进行连接,参数 addr 指定了待连接的服务器的 IP 地址以及端口号等信息,参数 addrlen 指定了 addr 指向的 struct sockaddr对象的字节大小。
客户端通过 connect()函数请求与服务器建立连接,对于 TCP 连接来说,调用该函数将发生 TCP 连接的握手过程,并最终建立一个 TCP 连接,而对于 UDP 协议来说,调用这个函数只是在 sockfd 中记录服务器IP 地址与端口号,而不发送任何数据。
函数调用成功则返回 0,失败返回-1,并设置 errno 以指示错误原因。
数据读写相关API
一旦客户端与服务器建立好连接之后,我们就可以通过套接字描述符来收发数据了(对于客户端使用socket()返回的套接字描述符,而对于服务器来说,需要使用 accept()返回的套接字描述符),这与我们读写普通文件是差不多的操作,譬如可以调用 read()或 recv()函数读取网络数据,调用 write()或 send()函数发送数据。
read()函数
read()函数大家都很熟悉了,通过 read()函数从一个文件描述符中读取指定字节大小的数据并放入到指定的缓冲区中,read()调用成功将返回读取到的字节数,此返回值受文件剩余字节数限制,当返回值小于指定的字节数时并不意味着错误;这可能是因为当前可读取的字节数小于指定的字节数(比如已经接近文件结尾,或者正在从管道或者终端读取数据,或者 read()函数被信号中断等),出错返回-1 并设置 errno,如果在调 read 之前已到达文件末尾,则这次 read 返回 0。
套接字描述符也是文件描述符,所以使用 read()函数读取网络数据时,read()函数的参数 fd 就是对应的套接字描述符。
recv()函数
recv()函数原型如下所示:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
不论是客户端还是服务器都可以通过 revc()函数读取网络数据,它与 read()函数的功能是相似的。参数sockfd 指定套接字描述符,参数 buf 指向了一个数据接收缓冲区,参数 len 指定了读取数据的字节大小,参数 flags 可以指定一些标志用于控制如何接收数据。
函数 recv()与 read()很相似,但是 recv()可以通过指定 flags 标志来控制如何接收数据,通常一般我们将 flags 参数设置为 0,当然,你可以根据自己的需求设置该参数。
write()函数
通过 write()函数可以向套接字描述符中写入数据,函数调用成功返回写入的字节数,失败返回-1,并设置 errno 变量。
send()函数
函数原型如下所示:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
send 和 write 很相似,但是 send 可以通过参数 flags 指定一些标志,来改变处理传输数据的方式。
即使 send()成功返回,也并不表示连接的另一端的进程就一定接收了数据,我们所能保证的只是当 send成功返回时,数据已经被无错误的发送到网络驱动程序上。
close()函数
当不再需要套接字描述符时,可调用 close()函数来关闭套接字,释放相应的资源。
参考示例
综合实践之TCP参考:
TCP(TCP客户端、服务器如何通信)_tcp客户端和服务器-CSDN博客
综合实践之UDP参考:
UDP编程流程(UDP客户端、服务器互发消息流程)_udp网络编程-CSDN博客
注意:在TCP连接中,服务器通常处于被动状态,等待客户端的连接请求。而客户端则处于主动状态,负责发起连接请求。一旦连接建立成功,双方就可以进行数据传输。
TCP和UDP通信过程对比图如下:
更多待补充。
更多参考:
【网络】网络编程_编写网络程序-CSDN博客
RPC
什么是rpc?
进程间通信-浅谈RPC- 最小原型_进程间rpc-CSDN博客
RPC框架:从原理到选型,一文带你搞懂RPC-CSDN博客
深入理解RPC:从原理到实战_深入理解rpc框架原理与实现 pdf-CSDN博客
RPC、Http、TCP/IP、Socket之间的关系及定义_socket rpc-CSDN博客
有了 HTTP 协议,为什么还要 RPC 协议,两者有什么区别?-腾讯云开发者社区-腾讯云 (tencent.com)
RPC核心原理和实战 —— 基础篇 | Focus-1
暂时了解下吧,后续有实际应用场景时再补充。
InternetSocket和UnixSocket
以前一直以为socket只是两个网络主机之间的进程通信,原来socket也可以用在一个主机上的两个进程之间的通信。
参考:
Internet socket和Unix socket_socket类型 internet unix-CSDN博客
unix socket通信,UNIX Domain SOCKET 是在Socket架构上发展起来的用于同一台主机的进程间通信(IPC)。它不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序列号应答等。只是将应用层数据从一个进程拷贝到另一个进程。
使用UNIX Domain Socket的过程和网络socket十分相似,也要先调用socket()创建一个socket文件描述符,address family指定为AF_UNIX,type可以选择SOCK_DGRAM或SOCK_STREAM,protocol参数仍然指定为0即可。
UNIX Domain Socket与网络socket编程最明显的不同在于地址格式不同,用结构体sockaddr_un表示,网络编程的socket地址是IP地址加端口号,而UNIX Domain Socket的地址是一个socket类型的文件在文件系统中的路径,这个socket文件由bind()调用创建,如果调用bind()时该文件已存在,则bind()错误返回。
无论是internet socket还是unix socket,服务端和客户端创建socket并进行通信的步骤都是一致的
服务端: socket -> bind -> listen -> accept -> write/send或read/recv -> close
客户端: socket -> connect -> write/send或read/recv -> close
并且每一步骤调用的函数也都是同样的函数,不同点主要在于
- socket函数domain字段的值不同,internet socket值为AF_INET,unix socket值为AF_UNIX
- socket函数的type参数的值都是SOCK_STREAM和SOCK_DGRAM,但是对于unix socket通信来说,由于是进程间的通信,这两种类型提供的服务都是可靠的
- bind函数中,internet socket传入的结构体是sockaddr_in,并且会绑定ip地址和端口号,unix socket传入的结构体是sockaddr_un,会绑定一个用于进程间通信的文件,但是都是需要进行强制转换的
- accept函数中,internet socket需要传入sockaddr_in结构体来保存发出请求的客户端的地址,但是在unix socket中,这一项为NULL,在进程通信中,在服务端上无法确定出来这个客户端进程是哪一个
后边的收发数据的调用过程都是一样的,总体来看,两种socket通信的步骤是一致的,但是用于是不一样的,一个是用于网络中两个主机之间的通信,另一个则是一个主机中两个进程之间的通信,这种一致性我觉得可能是因为linux系统中的一切皆文件的思想,无论是进程间通信或者是主机间通信,通过socket函数就能返回一个文件描述符,这个文件描述符的背后可能是用于网络传输的网络连接,或者是进程间的通信,但是对于编程者而言就是用于输入输出的一个文件描述符,这样进程间的通信和主机间的通信都被抽象成了对一个文件的输入输出操作,所以才造成了internet socket和unix socket通信的这种一致性;在细节方面,也正是因为有bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)函数,它提供了一个同一的结构体,在不同类型的socket编程时可以让sockaddr_un和sockaddr_in结构体强制类型转换,所以能够让编写不同类型的socket程序的时候可以调用同样的接口,才造成了在编程时候的这种一致性。
注意,UNIX计算机配置了一个只包含它自身的回路(loopback)网络,网络中只有一台计算机localhost,它有一个标准的IP地址127.0.0.1。
更多参考:
Linux的套接字——进程间通信的另一种方法_进程内部可以通过套接字进行通信吗-CSDN博客
套接字是一种通信机制,通过使用套接字接口,一台机器上的进程可以和另一台机器上的进程进行网络通信,同一台机器之间的进程也可以互相通信。