本文是关于Socket通信的大杂烩。我深知自己的局限性和不足之处,无论是在语言表达、逻辑思维还是知识储备上,都有许多需要改进和学习的地方。因此,如果您发现我在博客中有任何错误、不准确或者需要补充的地方,欢迎指正并提出建议。
Socket(套接字)是一种用于实现网络通信的编程接口(API),它提供了一种标准化的方式,使得不同操作系统和编程语言之间的应用程序能够相互通信。Socket最初是在BSD(Berkeley Software Distribution)操作系统中开发出来的,目的是为了实现在不同主机之间进行进程间通信。BSD是由加州大学伯克利分校开发的一个Unix操作系统的分支,它对Socket的定义和实现成为了事实上的标准。后来,由于互联网的发展,Socket被广泛应用于网络编程中。
在早期的计算机网络中,通信使用的是不同的协议,这些协议之间缺乏标准化的接口,使得应用程序的编写和移植变得非常困难。为了解决这个问题,一些计算机科学家开始研究如何定义一种标准的通信接口,以便不同的计算机之间能够进行通信。Socket就是在这个背景下诞生的,它提供了一种可移植、可扩展、易于使用的接口,使得应用程序能够在不同的操作系统和计算机之间进行通信。
随着互联网的发展,Socket成为了网络编程中不可或缺的一部分。它被广泛应用于各种网络应用程序中,如Web服务器、电子邮件客户端、聊天程序等。同时,随着计算机硬件和网络技术的不断发展,Socket也不断更新和完善,以适应新的应用场景和需求。
Socket通信主要是为了解决计算机网络中的进程间通信问题。在网络编程中,有两个进程需要进行通信才能完成特定的任务,这两个进程可能运行在不同的计算机上,也可能运行在同一台计算机上的不同进程中。Socket提供了一种标准化的接口,使得这些进程能够在网络中进行数据交换和通信。具体来说,Socket通信可以解决以下几个方面的问题:
总之,Socket通信可以为应用程序提供一种标准化、可靠、安全的网络通信方式,使得不同计算机之间的应用程序可以进行数据交换、信息共享和远程控制等操作。
Socket 的原意是“插座”,在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。Socket本质上是一个抽象层,它是一组用于网络通信的API,包括了一系列的函数和数据结构,它提供了一种标准的网络编程接口,使得应用程序可以在网络中进行数据传输。Socket本身并不是一个具体的实现,而是一个抽象的概念。不同的操作系统和编程语言可以通过不同的方式来实现Socket API。
通过 Socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。socket()函数返回一个整型的Socket描述符,随后的连接建立、数据传输等操作都是通过该Socket实现的。
Socket起源于Unix,而Unix/Linux基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。在Unix/Linux系统下,socket也是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭)。我们可以通过 socket() 函数来创建一个网络连接,或者说打开一个网络文件,socket() 的返回值就是文件描述符。有了文件描述符,我们就可以使用普通的文件操作函数来传输数据了,例如:
用 read() 读取从远程计算机传来的数据;
用 write() 向远程计算机写入数据。
只要用 socket() 创建了连接,剩下的就是文件操作了。
Socket可以类比成电话线路或电线路,就像电话线路或电线路提供了一条可靠的通信通道,使得两个地点之间可以进行语音或数据通信一样,Socket也提供了一条可靠的通信通道,使得两个计算机之间可以进行数据交换和通信。就像我们在打电话或发送信息时需要先建立连接、传输数据,然后再断开连接一样,Socket也需要先建立连接、传输数据,最后再关闭连接。而且就像电话线路或电线路可以支持不同的通信协议和数据类型一样,Socket也可以支持不同的网络协议和数据格式。
在网络编程中,Socket通常被用于实现以下几种类型的网络通信:
Socket通信实现的步骤一般如下:
针对TCP的Socket编程:
Socket编写流程
socket
,得到文件描述符;bind
,将 socket 绑定在指定的 IP 地址和端口;listen
,进行监听;accept
,等待客户端连接;connect
,向服务端的地址和端口发起连接请求;accept
返回用于传输的 socket
的文件描述符;write
写入数据;服务端调用 read
读取数据;close
,那么服务端 read
读取数据的时候,就会读取到了 EOF
,待处理完数据后,服务端调用 close
,表示连接关闭。这里需要注意的是,服务端调用 accept
时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
以下是Linux下的简单的socket编程示例:
Linux下的socket演示程序
本文论述及实现均在Linux环境中
在 Linux 下使用 <sys/socket.h> 头文件中 socket() 函数来创建套接字,原型为:
int socket(int af, int type, int protocol);
(2)type 为数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字) 和 SOCK_DGRAM(数据报套接字/无连接的套接字)
(3)protocol 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。
int tcp_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); //IPPROTO_TCP表示TCP协议
这种套接字称为 TCP 套接字。
如果使用 SOCK_DGRAM 传输方式,那么满足这两个条件的协议只有 UDP,因此可以这样来调用 socket() 函数:
int udp_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); //IPPROTO_UDP表示UDP协议
这种套接字称为 UDP 套接字。
上面两种情况都只有一种协议满足条件,可以将 protocol 的值设为 0,系统会自动推演出应该使用什么协议,如下所示:
int tcp_socket = socket(AF_INET, SOCK_STREAM, 0); //创建TCP套接字
int udp_socket = socket(AF_INET, SOCK_DGRAM, 0); //创建UDP套接字
以下实现均在Linux环境中
bind() 函数的原型为:
int bind(int sock, struct sockaddr *addr, socklen_t addrlen); //Linux
sock 为 socket 文件描述符,addr 为 sockaddr 结构体变量的指针,addrlen 为 addr 变量的大小,可由 sizeof() 计算得出。
sockaddr 结构体的定义如下:
struct sockaddr{
sa_family_t sin_family; //地址族(Address Family),也就是地址类型
char sa_data[14]; //IP地址和端口号
};
这两个结构体的长度相同,强制转换类型时不会丢失字节,也没有多余的字节。
struct sockaddr_in{
sa_family_t sin_family; //地址族(Address Family),也就是地址类型
uint16_t sin_port; //16位的端口号
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //不使用,一般用0填充
};
代码解析:
sin_family 和 socket() 的第一个参数的含义相同,取值也要保持一致。
sin_prot 为端口号。uint16_t 的长度为两个字节,理论上端口号的取值范围为 0~65536,但 0~1023 的端口一般由系统分配给特定的服务程序,例如 Web 服务的端口号为 80,FTP 服务的端口号为 21,所以我们的程序要尽量在 1024~65536 之间分配端口号。端口号需要用 htons() 函数转换。
sin_addr 是 struct in_addr 结构体类型的变量。该结构体只包含一个成员,如下所示:
struct in_addr{ in_addr_t s_addr; //32位的IP地址
};
unsigned long ip = inet_addr("127.0.0.1");
printf("%ld\n", ip);
运行结果:
16777343
sin_zero[8] 是多余的8个字节,没有用,一般使用 memset() 函数填充为 0。上面的代码中,先用 memset() 将结构体的全部字节填充为 0,再给前3个成员赋值,剩下的 sin_zero 自然就是 0 了。
struct sockaddr_in6 {
sa_family_t sin6_family; //(2)地址类型,取值为AF_INET6
in_port_t sin6_port; //(2)16位端口号
uint32_t sin6_flowinfo; //(4)IPv6流信息
struct in6_addr sin6_addr; //(4)具体的IPv6地址
uint32_t sin6_scope_id; //(4)接口范围ID
};
connect() 函数用来建立连接,它的原型为:
int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen);
各个参数的说明和 bind() 相同,sock 为 socket 文件描述符,addr 为 sockaddr 结构体变量的指针,addrlen 为 addr 变量的大小,可由 sizeof() 计算得出。
对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状态,再调用 accept() 函数,就可以随时响应客户端的请求了。
listen() 函数
listen函数的原型为:
int listen(int sock, int backlog); //Linux
sock 为需要进入监听状态的套接字,backlog 为请求队列的最大长度。
所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。
请求队列
当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。
缓冲区的长度(能存放多少个客户端请求)可以通过 listen() 函数的 backlog 参数指定。
在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。
但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。
如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。
当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误。
accept() 函数
当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求。其原型为:
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen); //Linux
它的参数与 listen() 和 connect() 是相同的:sock 为服务器端套接字,addr 为 sockaddr_in 结构体变量,addrlen 为参数 addr 的长度,可由 sizeof() 求得。
需要说明的是: listen() 只是让套接字进入监听状态,并没有真正接收客户端请求,listen() 后面的代码会继续执行,直到遇到 accept()。accept() 会阻塞程序执行(后面代码不能被执行),直到有新的请求到来。
Linux下的数据的接收和传送
Linux 不区分套接字文件和普通文件,使用 write() 可以向套接字中写入数据,使用 read() 可以从套接字中读取数据。
两台计算机之间的通信相当于两个套接字之间的通信,在服务器端用 write() 向套接字写入数据,客户端就能收到,然后再使用 read() 从套接字中读取出来,就完成了一次通信。
write() 的原型为:
ssize_t write(int fd, const void *buf, size_t nbytes);
size_t 是通过 typedef 声明的 unsigned int 类型;ssize_t 在 “size_t” 前面加了一个"s",代表 signed,即 ssize_t 是通过 typedef 声明的 signed int 类型。
write()函数功能:write() 函数会将缓冲区 buf 中的 nbytes 个字节写入文件 fd,成功则返回写入的字节数,失败则返回 -1。
read() 的原型为:
ssize_t read(int fd, void *buf, size_t nbytes);
read() 函数功能:read() 函数会从 fd 文件中读取 nbytes 个字节并保存到缓冲区 buf,成功则返回读取到的字节数(但遇到文件结尾则返回0),失败则返回 -1。
Socket 通信的关键底层基础技术主要包括以下几点:
OSI 模型(Open Systems Interconnection Model):这是一个用于描述网络通信系统的概念模型。它将网络通信划分为七个层次,从物理层到应用层。Socket 通信主要涉及到传输层(TCP/UDP)和应用层。
TCP/IP 协议族: Socket 通信主要依赖于 TCP/IP 协议族。TCP/IP 协议族包括一组互相关联的网络协议,例如 IP(Internet Protocol,网络层协议)、TCP(Transmission Control Protocol,传输层协议)、UDP(User Datagram Protocol,传输层协议)等。Socket 通信使用 TCP 或 UDP 协议进行数据传输。
TCP连接建立时的三次握手过程:
TCP 三次握手过程是怎样的?
TCP连接断开时的四次挥手过程:
TCP 四次挥手过程是怎样的?
套接字(Socket): 套接字是一种用于网络通信的编程接口。它允许应用程序通过网络层(如 IP)和传输层(如 TCP 或 UDP)协议进行通信。套接字提供了一种在不同设备之间传输数据的通用方法,使得网络编程更加简单易懂。
流格式套接字(Stream Sockets)也叫“面向连接的套接字”,在代码中使用 SOCK_STREAM 表示。
SOCK_STREAM 是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。
SOCK_STREAM 有以下几个特征:
“数据传输过程不会消失”“数据是按照顺序传输”
可以将 SOCK_STREAM 比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失;同时,较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按照顺序传递的。
之所以流格式套接字可以达到高质量的数据传输,因为它使用了TCP协议,TCP协议会控制你的数据按照顺序达到且没有错误。
“数据的发送和接收不同步” 该如何理解?
假设传送带是传送柚子,接收者需要凑齐20个柚子才能装袋,但因为柚子摆放位置不同,传送带可能会把这些柚子分批传送。第一批5个,第二批10个,第三批5个。接收者不需要和传送带保持同步,也不管传送带传送了几批,也不用每到一批就装袋一次,只需要凑够20个柚子再装袋即可。
流格式套接字的内部有一个缓冲区(也就是字符数组),通过 socket 传输的数据将保存到这个缓冲区。接收端在收到数据后并不一定立即读取,只要数据不超过缓冲区的容量,接收端有可能在缓冲区被填满以后一次性地读取,也可能分成好几次读取。
也就是说,不管数据分几次传送过来,接收端只需要根据自己的要求读取,不用非得在数据到达时立即读取。传送端有自己的节奏,接收端也有自己的节奏,它们是不一致的。
流格式套接字实际的应用场景有什么?浏览器所使用的 http 协议就基于面向连接的套接字,因为必须要确保数据准确无误,否则加载的 HTML 将无法解析。
数据报格式套接字(Datagram Sockets)也叫“无连接的套接字”,在代码中使用 SOCK_DGRAM 表示。
计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。因为数据报套接字所做的校验工作少,所以在传输效率方面比流格式套接字要高。
可以将 SOCK_DGRAM 比喻成高速移动的摩托车快递,它有以下特征:
众所周知,速度是快递行业的生命。用摩托车发往同一地点的两件包裹无需保证顺序,只要以最快的速度交给客户就行,而且由于天气恶劣或人为失误,快递就有可能会延迟或遗失包裹。这种方式存在损坏或丢失的风险,而且包裹大小有一定限制。因此,想要传递大量包裹,就得分配发送。
另外,用两辆摩托车分别发送两件包裹,那么接收者也需要分两次接收,所以“数据的发送和接收是同步的”;换句话说,接收次数应该和发送次数相同。
总之,数据报套接字是一种不可靠的、不按顺序传递的、以追求速度为目的的套接字。
数据报套接字也使用 IP 协议作路由,但是它不使用 TCP 协议,而是使用 UDP 协议(User Datagram Protocol,用户数据报协议)。
QQ 视频聊天和语音聊天就使用 SOCK_DGRAM 来传输数据,因为首先要保证通信的效率,尽量减小延迟,而数据的正确性是次要的,即使丢失很小的一部分数据,视频和音频也可以正常解析,最多出现噪点或杂音,不会对通信质量有实质的影响。
IP、MAC和端口号——网络通信中确认身份信息的三要素
端口(Port): 端口号是一个用于标识网络服务或应用程序的数字。
为了区分不同的网络程序,计算机会为每个网络程序分配一个独一无二的端口号(Port Number)。端口号范围从 0 到 65535,其中 0 到 1023 通常保留给系统或者众所周知的服务(如 HTTP、FTP 等)。例如,Web 服务的端口号是 80,FTP 服务的端口号是 21,SMTP 服务的端口号是 25。
端口(Port)是一个虚拟的、逻辑上的概念。可以将端口理解为一道门,数据通过这道门流入流出,每道门有不同的编号,就是端口号。如下图所示:
Socket通信的优势:
网络通信协议的灵活性:Socket可以支持各种网络通信协议,如TCP、UDP、HTTP等,具有很强的通用性和灵活性,可以满足不同的网络应用需求。
数据传输的可靠性:Socket提供了面向连接的通信方式,可以保证数据传输的可靠性和完整性,避免数据丢失、重复和损坏等问题。
系统资源的高效利用:Socket可以使用多线程和多进程技术,实现并发处理多个客户端的请求,充分利用系统资源,提高通信效率和吞吐量。
程序设计的灵活性:Socket可以使用各种编程语言和工具进行开发,程序设计灵活性强,可以根据具体需求进行定制化开发。
Socket通信的劣势:
1.网络通信的安全性问题:Socket通信存在网络安全方面的问题,如数据窃听、篡改、伪造等,需要采取一些安全措施来保障通信安全。
2.网络环境的不稳定性:Socket通信受到网络环境的影响,如网络延迟、丢包、拥塞等,会影响通信效率和可靠性。
3.系统资源占用较高:Socket通信需要占用一定的系统资源,如内存、CPU等,如果同时处理大量的客户端请求,可能会占用大量的系统资源,导致系统负载过高。
进行Socket通信时,可能会出现各种错误,例如网络异常、连接中断、超时等,为了保证程序的稳定性和可靠性,需要进行Socket通信的错误处理,具体方法如下:
检测错误:在进行Socket通信时,需要检测每个Socket操作的返回值,如果返回值小于0,则表示发生了错误。
处理错误:对于发生的错误,需要进行相应的处理,例如打印错误信息、关闭Socket连接、重试Socket操作等。
恢复连接:在Socket连接中断时,需要进行连接恢复的操作,例如重新连接、重试连接等。
超时处理:对于Socket操作超时的情况,需要进行相应的处理,例如重新进行Socket操作、关闭Socket连接等。
使用异常处理机制:在进行Socket通信时,可以使用异常处理机制来处理异常情况,例如使用try-catch语句捕获异常并进行相应的处理。
记录日志:在进行Socket通信时,可以记录日志来跟踪错误和调试程序,以便快速定位和解决问题。
Socket通信常见的优化方法包括以下几个方面:
使用非阻塞式Socket:非阻塞式Socket可以避免等待Socket返回数据时程序出现阻塞的情况,从而提高程序的并发性能和响应速度。
使用多线程或多进程:通过使用多线程或多进程的方式,可以将Socket通信分配到不同的线程或进程中处理,从而提高程序的并发性能和响应速度。
使用异步I/O:异步I/O可以在等待Socket返回数据的同时,执行其他操作,可以提高程序的并发性能和响应速度。
调整TCP参数:通过调整TCP参数可以改善Socket通信的性能,例如调整TCP窗口大小、超时时间等,还有提升TCP三次握手、四次挥手、传输数据的性能。(参考: 如何优化TCP?)
使用缓存技术:使用缓存技术可以减少Socket通信中的数据传输次数,从而提高程序的吞吐量和响应速度。
使用零拷贝技术:零拷贝技术可以在数据传输过程中减少数据的复制次数,从而减少CPU和内存的消耗,提高程序的性能和吞吐量。
减少Socket连接的建立和关闭次数:Socket连接的建立和关闭过程会消耗一定的资源和时间,减少Socket连接的建立和关闭次数可以提高程序的性能和稳定性。
优化数据传输格式:优化数据传输格式可以减少数据传输量,从而提高程序的吞吐量和响应速度。
Socket通信的优化需要根据具体的应用场景和需求进行调整,需要综合考虑网络带宽、数据传输量、网络延迟、系统资源等因素,以达到最优的性能和稳定性。
Socket 通信是一种基于网络的底层通信方式,允许不同设备之间实现数据传输。它适用于多种业务场景和技术场景。以下分别列举了一些典型的应用:
业务场景
技术场景
《TCP/IP详解 卷1:协议》
小林coding
C语言中文网—Socket通信
1.什么是Socket通信,它的作用是什么?
2.Socket通信的两种协议是什么?它们有什么区别?
3.Socket通信的五种基本操作是什么?
4、Socket通信中的阻塞和非阻塞模式有什么区别?
5.Socket通信中的TCP和UDP协议有什么区别?它们应该在什么场景下使用?
6.什么是服务器和客户端,它们在Socket通信中的作用是什么?
7.如何进行Socket通信的错误处理?
8.Socket通信中的数据传输方式有哪些?它们的优缺点是什么?
10.如何进行Socket通信的性能优化?有哪些常见的优化方法?