C++ 中的 Socket
Socket
《TCP/IP 网络编程》和《Linux高性能服务器编程》
socket 其实就是操作系统提供给程序员操作「网络协议栈」的接口,开发人员能通过 socket 的接口,来控制协议找工作,从而实现网络通信,达到跨主机通信
socket一般分为TCP网络编程和UDP网络编程
C中的socket
socket()函数
函数声明
1 | |
形参解释
domain: 即协议域,又称为协议族(Protocol family),协议族决定了socket的协议类型,在通信中必须采用对应的地址,常用的socket协议域有PF_INET、PF_INET6、PF_LOCAL(或称PF_UNIX,Unix域socket)、RF_ROUTE等等PF_INET: 表示IPv4地址PF_INET6: 表示IPv6地址- ...
type: 指定socket类型,常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等SOCK_STREAM: 流式套接字,使用这种套接字时,数据在客户端是顺序发送的,并且到达的顺序是一致的。比如你在客户端先发送1,再发送2,那么在服务器端的接收顺序是先接收到1,再接收到2,流式套接字是可靠的,是面向连接的;SOCK_STREAM: 数据报套接字,这种套接字是无连接的,数据是打包成数据包发送的,到达的顺序不一定与发送的顺序是一致的,并且数据不一定是可达的,并且接收到的数据还可能出错。SOCK_RAW: 原始套接字,Raw Socket广泛应用于高级网络编程,也是一种广泛的黑客手段。著名的网络sniffer、拒绝服务攻击(DOS)、IP欺骗等都可以以Raw Socket实现。Raw Socket直接置"根"于操作系统网络核心(Network Core)。当我们使用Raw Socket的时候,可以完全自定义IP包,一切形式的包都可以"制造"出来。
protocol: 指定socket协议,常用的socket协议有IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议
注意:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。
当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。
bind()函数
bind()函数把一个地址族中的特定地址赋给socket,例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket
函数声明
1 | |
形参解释
sockfd: 即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字addr: 一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,ipv4对应的是:
1
2
3
4
5
6
7
8
9
10struct sockaddr_in {
sa_family_t sin_family; // 地址族(Address Family)
in_port_t sin_port; // 16位TCP/UDP端口号
struct in_addr sin_addr; // 32位IP地址
char sin_zero[8];// 一般不使用,置0
};
struct in_addr {
uint32_t s_addr; // 32位IP地址
};ipv6对应的是:
1
2
3
4
5
6
7
8
9
10
11struct sockaddr_in6 {
sa_family_t sin6_family;
in_port_t sin6_port;
uint32_t sin6_flowinfo;
struct in6_addr sin6_addr;
uint32_t sin6_scope_id;
};
struct in6_addr {
unsigned char s6_addr[16];
};
addrlen: 对应的地址长度,可以用sizeof(addr)获取
listen()、connect()函数
如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果作为一个客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
函数声明
1 | |
形参解释
sockfd: socket描述字backlog: 指相应socket可以排队的最大连接个数addr: 服务器的socket地址addrlen: socket地址的长度
相关要点
socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求;
客户端通过调用connect函数来建立与TCP服务器的连接
在调用connect之前不必非得调用bind函数,如果需要的话,内核会确定源IP地址,并选择一个临时端口作为源端口
TCP套接字connect错误
- 若TCP客户端没有收到syn分节的响应,则返回ETIMEOUT错误;调用connect函数时,内核发送一个syn,若无响应则等待6s后再发送一个,若仍然无响应则等待24s后在发送一个,若总共等待75s后仍未收到响应则返回本错误;
- 若对客户的syn响应是rst,则表明该服务器在我们指定的端口上没有进程在等待与之连接,这是一种硬错误,客户一收到rst马上返回ECONNREFUSED错误;
- 若客户发送的syn在中间的某个路由器上引发了目的不可达icmp错误,则认为是一种软错误。客户主机内核保存该消息,并按照第一种情况的时间间隔继续发送syn,咋某个规定时间后仍未收到响应,则把保存的消息作为EHOSTUNREACH或者ENETUNREACH错误返回给进程;
accept()函数
TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
函数声明
1 | |
相关要点
- 如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接
- accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字
- 一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭
read()、write()等函数
服务器与客户端建立好连接后就可以调用网络I/O进行读写操作了,即实现了网咯中不同进程之间的通信,网络I/O操作有下面几组: - read()/write() - recv()/send() - readv()/writev() - recvmsg()/sendmsg() - recvfrom()/sendto()
推荐使用recvmsg()/sendmsg()函数,或者recv()/send(),在
Linux 下你用 read 和 write 的话,文件和 socket
两者都能读写,只是无法直接设置一些特殊的 flag
函数声明
1 | |
read/write函数详解
read函数是负责从fd中读取内容。当读取成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。如果错误为EINTR说明读是由中断引起的,如果是ECONNREST表示网络连接出了问题。
write函数将buf中的nbytes字节内容写入文件描述符fd,成功时返回写的字节数。失败时返回-1,并设置errno变量。在网络程序中,当我们向套接字文件描述符write时有两种可能
- 返回值大于0,表示写了部分或者是全部的数据。
- 返回的值小于0,此时出现了错误。我们要根据错误类型来处理。如果错误为EINTR表示在写的时候出现了中断错误。如果为EPIPE表示网络连接出现了问题(对方已经关闭了连接)。
注:其他几对I/O函数可以使用man或者google进行了解
close()函数
在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
函数声明
1 | |
相关要点
- close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数
- close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求
Protocol families & Address families
协议簇(Protocol
families):用来指定socket通信过程中的协议,也就是socket()函数的第一个参数
地址簇(Address
families):用来指定socket的地址类型,也就是struct sockaddr的sin_family字段
其中在一些教程中出现了两者的混用,例如AF_INET, PF_INET两者可以相互使用,在Windows中并无差异,但在Unix/Linux系统中不同版本两者有细微差别。
因此理论上,建立socket时是指定协议,应该用PF_XXXX,设置地址时应该用AF_XXXX。当然AF_INET和PF_INET的值是相同的,混用也不会有太大的问题。
其他函数
setsockopt()
1 | |
setsocket函数用作对socket设置一些选项,或者说是属性,以便于完成更好的功能,其中sockfd指的是待设置选项的socket文件描述符;,例如SOL_SOCKET是在socket
API
level;optval指的是选项名;optlen指的是选项大小,一般为sizeof(optval)。
例如在一个应用场景中:TCP连接recv()等函数默认为阻塞模式(block),即直到有数据到来之前函数不会返回,而我们有时则需要一种超时机制使其在一定时间后返回而不管是否有数据到来,这里我们就会用到setsockopt()函数。
另外一个应用场景:一般来说一个端口释放后会等待两分钟之后才能再被使用,SO_REUSEADDR是让端口释放后立即就可以被再次使用。server程序总是应该在调用bind()之前设置SO_REUSEADDR套接字选项,即一般服务器的监听socket都应该打开它,它的大意是允许服务器bind一个地址,即使这个地址当前已经存在已建立的连接。
- 服务器启动后,有客户端连接并已建立,如果服务器主动关闭,那么和客户端的连接会处于TIME_WAIT状态,此时再次启动服务器,就会bind不成功,报:Address already in use。
- 服务器父进程监听客户端,当和客户端建立链接后,fork一个子进程专门处理客户端的请求,如果父进程停止,因为子进程还和客户端有连接,所以再次启动父进程,也会报Address already in use。
通过下面这段代码就可以实现对该应用场景的问题解决
1 | |
getsockname()
1 | |
getsockname函数用作返回指定sockfd的sockaddr值,也就是将bind后的地址信息写入到第二个参数中,通常用作获取用户指定的端口号sin_port(一般是指bind时sin_port为0的情况)
pthread_create()
1 | |
pthread_create函数用于在调用过程中创建一个新的线程,新线程通过调用start_routine()开始执行;
arg 作为start_routine()的唯一参数传递。
sockaddr & sockaddr_in
struct sockaddr和struct sockaddr_in这两个结构体用来处理网络通信的地址。
sockaddr
sockaddr在头文件#include <sys/socket.h>中定义,sockaddr的缺陷是:sa_data把目标地址和端口信息混在一起了,如下:
1 | |
sockaddr_in
sockaddr_in在头文件#include<netinet/in.h>或#include <arpa/inet.h>中定义,该结构体解决了sockaddr的缺陷,把port和addr
分开储存在两个变量中,如下:
1 | |
Tips:当sin_addr为0时,经过bind()系统会自动指定一个端口,可以通过getsockname()函数获取系统指定的端口号。
注意事项
sin_port和sin_addr都必须是网络字节序(NBO),一般可视化的数字都是主机字节序(HBO),转换函数如下:
1 | |
网络字节序NBO
网络上传输的数据都是字节流,对于一个多字节数值,在进行网络传输的时候,先传递哪个字节?也就是说,当接收端收到第一个字节的时候,它将这个字节作为高位字节还是低位字节处理,是一个比较有意义的问题;
UDP/TCP/IP协议规定:把接收到的第一个字节当作高位字节看待,这就要求发送端发送的第一个字节是高位字节;而在发送端发送数据时,发送的第一个字节是该数值在内存中的起始地址处对应的那个字节,也就是说,该数值在内存中的起始地址处对应的那个字节就是要发送的第一个高位字节(即高位字节存放在低地址处);由此可见,多字节数值在发送之前,在内存中因该是以大端法存放的;
所以说,网络字节序是大端字节序;在实际中,当在两个存储方式不同的主机上传输时,需要借助字节序转换函数。
总结
- sockaddr、sockaddr_in两者长度一样,都是16个字节,即占用的内存大小是一致的,因此可以互相转化;
- sockaddr、sockaddr_in两者是并列结构,指向sockaddr_in结构的指针也可以指向sockaddr;
- sockaddr常用于bind、connect、recvfrom、sendto等函数的参数,指明地址信息,是一种通用的套接字地址;sockaddr_in 是internet环境下套接字的地址形式
在网络编程中我们会对sockaddr_in结构体进行操作,使用sockaddr_in来建立所需的信息,最后使用类型转化就可以了。一般先把sockaddr_in变量赋值后,强制类型转换后传入用sockaddr做参数的函数:sockaddr_in用于socket定义和赋值;sockaddr用于函数参数
举个例子
1 | |
其中两个函数 htons() 和 inet_addr():
htons()作用是将端口号由主机字节序转换为网络字节序的整数值。(host to net)
inet_addr()作用是将一个IP字符串转化为一个网络字节序的整数值,用于
sockaddr_in.sin_addr.s_addrinet_ntoa()作用是将一个sin_addr结构体输出成IP字符串(network to ascii)
1
printf("%s",inet_ntoa(mysock.sin_addr));inet_aton()将一个字符串IP地址转换为一个32位的网络序列IP地址。如果这个函数成功,函数的返回值非零,如果输入地址不正确则会返回零
三种给socket赋值地址的方法
1 | |
两种给socket赋值端口的方法
1 | |
再举个例子
服务器端
1 | |
客户端
1 | |
通讯过程
// todo
Java服务器端
socket编程的套路是一定的,客户端和服务器端都是固定不变的编写套路,唯一的差别就在于不同的语言和系统之间可能编码不同、API不同而已。
Java服务器端的代码简单实现
1 | |
*一种参考的C客户端的代码实现
1 | |