转载
最近更新: 2022/11/27 22:51

socket编程

1 初级socket编程

Socket原理讲解

Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。

基于TCP的Socket编程

基于TCP的Socket编程

基于UDP的Socket编程

基于UDP的Socket编程

socket起源于Unix,而Unix/Linux基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。socket即是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭)。

1.1 socket()函数

int socket(int domain, int type, int protocol);
//domain表示协议域,如AF_INET表示使用32位的ipv4地址与16位的端口号组合
//type指定socket类型,SOCK_STREAM表示字节流套接字,用于TCP;SOCK_DGRAM表示数据报套接字,用于UDP。
//protocol就是指定协议,如IPPROTO_TCP、IPPTOTO_UDP等。

socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符,它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。

1.2 bind()函数

bind()函数把一个地址族中的特定地址与socket绑定。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//sockfd是socket()函数创建的标识符
//addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。addrlen是地址的长度。
//以下是ipv4的地址结构,其中in表示inet4
struct sockaddr_in {
    sa_family_t    sin_family;
    in_port_t      sin_port;
    struct in_addr sin_addr;
};

struct in_addr {
    uint32_t       s_addr;
};
/*
应当注意的是,在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序(大端序)。
inet_ntoa()可以将一个in_addr结构体输出成IP字符串(network to ascii)。
如:printf("%s", inet_ntoa(mysock.sin_addr));
*/

//==============例子===============
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main(int argc,char **argv)
{
    int sockfd;
    struct sockaddr_in mysock;

    sockfd = socket(AF_INET,SOCK_STREAM,0);  //获得fd

    bzero(&mysock,sizeof(mysock));  //初始化结构体为0
    mysock.sin_family = AF_INET;  //设置地址家族为ipv4
    mysock.sin_port = htons(800);  //设置端口
	//htons()作用是将端口号由主机字节序转换为网络字节序的整数值。(host to net)
    mysock.sin_addr.s_addr = inet_addr("192.168.1.0");  //设置地址
	//inet_addr()作用是将一个IP字符串转化为一个网络字节序的整数值,用于sockaddr_in.sin_addr.s_addr。
    bind(sockfd,(struct sockaddr *)&mysock,sizeof(struct sockaddr); /* bind的时候进行转化 */
    ... ...
    return 0;
}

1.3 listen()、connect()函数

服务器在调用socket()、bind()之后,就会调用listen()来监听这个socket。

如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。

connect相当于TCP三次握手中的第一步SYN。

int listen(int sockfd, int backlog);
//backlog表示相应socket可以排队的最大连接个数。
//socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//参数分别为客户端的socket描述字、服务器的socket地址、socket地址的长度。
//客户端通过调用connect函数来主动建立与TCP服务器的连接。

1.4 accept()函数

TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。

TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。

TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。

connect相当于TCP三次握手中的第二步SYN。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//第一个参数为服务器的监听socket描述字
//第二个参数是指向struct sockaddr *的指针,用于返回客户端的协议地址
//第三个参数为客户端协议地址的长度。
//如果accept成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。
//即服务器用一个新的socket(端口)与客户端进行通信,旧端口依旧在监听。

1.5 read()、write()等函数

至此服务器与客户已经建立好连接了,可以调用网络I/O进行读写操作了。

//read函数是负责从fd中读取内容。当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。
ssize_t read(int fd, void *buf, size_t count);
//write函数将buf中的nbytes字节内容写入文件描述符fd。成功时返回写的字节数;失败时返回-1,并设置errno变量。
ssize_t write(int fd, const void *buf, size_t count);

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

//这两个是UDP常用的读写方法
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
			   const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
				 struct sockaddr *src_addr, socklen_t *addrlen);

ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

1.6 close()函数

用于关闭相应的socket描述字,相当于四次挥手中的FIN报文。

int close(int fd);
//close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。
//该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。
//close操作只是使相应socket描述字的引用计数-1(可能多个进程都使用这一个套接字),只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。

2 高级socket编程

CS-Note-Socket

一般的IO操作,比如read和write,通常都是阻塞式I/O的,也就是说当你调用read时,如果没有数据收到,那么线程或者进程就会被挂起,直到收到数据。

当服务器同时存在大量TCP连接时,每个连接都用一个线程来管理,会导致服务器中有大量被挂起的线程。而如果每个read都很短,就会导致大量的线程切换。

非阻塞式I/O

线程调用read之后,如果没有数据,内核返回一个错误码,不挂起继续执行。此时需要线程不断轮询检查 I/O 是否完成。CPU 利用率比较低。

I/O多路复用

此时就诞生了I/O多路复用,即用一个线程管理多个连接。I/O多路复用又叫事件驱动,一个线程在管理多个连接时,会自动等待并接收套接字的数据。

I/O 复用不需要进程线程创建和切换的开销,系统开销更小。

I/O多路复用的具体实现在时间上先后分三种:select, poll, epoll。

select和poll

select

select是最早的I/O复用技术,允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作。底层是数组实现。

select默认最多只能监听1024个描述符。

poll

poll 的功能与 select 类似,也是等待一组描述符中的一个成为就绪状态。

poll 没有描述符数量的限制;poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。

select内部使用数组实现,poll是链表。他们需要做内核区到用户区的转换,还需要做数据拷贝,因此效率低。

epoll

int epoll_create(int size);
//epoll_ctl用于向内核注册、修改、删除某个文件描述符的状态。
//已注册的描述符在内核中会被维护在一棵红黑树上
//内核会通过回调函数将 I/O 准备好的描述符加入到一个链表中管理
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//进程调用 epoll_wait() 便可以得到事件完成的描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll 只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。

epoll不需要做内核区到用户区的转换,因为数据存在共享内存中。epoll维护的树在共享内存中,内核区和用户区去操作共享内存,因此不需要区域转换,也不需要拷贝操作。

epoll 比 select 和 poll 更加灵活而且没有描述符数量限制。

工作模式

LT 模式

当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。

是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。

ET 模式

和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。

很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

应用场景

很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。

select 应用场景

select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。

select 可移植性更好,几乎被所有主流平台所支持。

poll 应用场景

poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。

epoll 应用场景

只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。

需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。

需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。

评论区