其实对于socket:我们需要理解的是他提供了一种编程概念,利用socket就可以利用系统已经封装好的内部进行通信,我们只需要关注应用层方面的数据控制就OK了。
一. 套接字(socket)
socket英文为插座的意思,也就是为用户提供了一个接入某个链路的接口。而在计算机网络中,一个IP地址标识唯一一台主机,而一个端口号标识着主机中唯一一个应用进程,因此“IP+端口号”就可以称之为socket。
两个主机的进程之间要通信,就可以各自建立一个socket,其实可以看做各自提供出来一个“插座”,然后通过连接上“插座”的两头也就是由这两个socket组成的socket pair就标识唯一一个连接,以此来表示网络连接中一对一的关系。
我们先来简单的了解一下socket究竟是什么东东。
首先对于套接字编程,他需要指定的套接字的地址作为参数,所以在网络协议中存在不同的协议,即存在不同的地址结构定义方式。对于这些结构都是sockaddr_开头的。每一个协议有一个唯一的后缀,例如以太网的就是sockaddr_in,这个常用。
然后我们来看一下通用的套接字结构:
struct sockaddr{ sa_family sa_family; char sa_data[14]; }
这个是套接字的原型,注意在套接字编程中,关于sockaddr_的函数都需要进行类型转换转换为sockaddr.
以太网中常用的套接字结构是:
对应关系是:
sin_family:对应的是地址类型:AF_INET代表ipv4。
sin_port:代表端口号。
sin_addr.s_addr:代表我们所建立的ip地址。
在编程之前,我们需要关注的是,在计算机中,字节序的存储分为大端小段,在网络字节序中,利用的是大端状态。而计算机可能存在大端可能存在小端,所以就存在一些字节序的转换函数:
就如函数名一样。host字节序to转换为net字节序l long4字节长度,余下函数同理。
然后我们还需要关注的是,在我们的sockaddr_in中,ip地址存在sin_addr.s_addr的类型,字符串的点分十进制的类型,还有二进制的类型,所以就有一系列的ip地址结构转换函数:
根据这几个函数的输入参数和输出参数可以看到他们的转换时从什么转换到什么,
然后还有2个安全转换的函数:
这2个函数是针对不同协议族的地址转换,第一个参数就代表网络类型协议族。
在基于上面的了解情况下,我们了解一下socket基于Tcp协议实现可靠传输的连接传输释放过程:
然后我们需要注意一下几个问题:
- 客户端与服务器的交互过程:
客户端的连接过程,对服务端是接收过程。然后在过程中进行3次握手建立TCP连接。
客户端与服务端之间的数据交换是相对的过程,客户端的读数据对应的是服务器端的写数据过程。客户端的写数据对应服务器的读数据过程。
在交互完毕后,关闭套接字连接。
下面开始套接字编程的认识。
首先我们来看一下代码,然后进行讲解:
首先来看服务端的代码:
#include<stdio.h> #include<unistd.h> #include<sys/socket.h> #include<sys/types.h> #include<string.h> #include<error.h> #include<arpa/inet.h> #define _PROT_ 8888 #define _BACKLOG_ 5 void process_conn_server(int s) { ssize_t size = 0; char buffer[1024]; while(1) { size = read(s,buffer,1024); if(size == 0) { return ; } sprintf(buffer,"%d bytes altongether\n",size); write(s,buffer,strlen(buffer) + 1); } } int main() { int ss,sc; struct sockaddr_in server_sock; struct sockaddr_in client_sock; pid_t pid; int err; ss = socket(AF_INET,SOCK_STREAM,0); if(ss < 0) { printf("sock build error"); return 1; } bzero(&server_sock,sizeof(server_sock)); server_sock.sin_family = AF_INET; server_sock.sin_addr.s_addr = htonl(INADDR_ANY); server_sock.sin_port = htons(_PROT_); err = bind(ss,(struct sockaddr *)&server_sock,sizeof(server_sock)); if(bind < 0) { printf("bind error"); return 2; } err = listen(ss,_BACKLOG_); if(err < 0) { printf("listen is error"); return 3; } while(1) { socklen_t len = sizeof(struct sockaddr); sc = accept(ss,(struct sockaddr *)&client_sock,&len); if(sc < 0) { continue; } pid = fork(); if(pid == 0) { process_conn_server(sc); close(ss); } else { close(sc); } } return 0; }
对于这些代码,首先来了解一下服务器要是先套接字所需要进行的函数调用过程,然后在来讨论一些效率和安全性的问题。
首先我们需要创建网络插口函数socket():
domain设置网络通信域,在以太网中使用AF_INET。
type是设置套接字通信的类型,TCP是面向字节流传输,所以使用SOCK_STREAM。
protocol通常设置为0;
对于这个函数而言,他就是为了我们的通信而打开一个文件描述符,调用成功就返回文件描述符方便数据传输,失败就返回-1.同时传出错误值。
在socket编程中的这一套函数中,都进行了底层的封装,提供了相对的借口,
我们来看一下socket()的内核实现:
用户调用socket()后,
系统调用sys_socket,其中
- 生成内核socket结构。
- 与文件描述符绑定。将绑定文件描述符值传给应用层。
当建立套接字文件描述符成功后,就需要对套接字进行地址和端口绑定,这时候就是bind()函数,同时我们需要创建一个sockaddr_in 的结构体来进行绑定。
来看一下函数:
第一个参数就是我们调用socket所返回的文件描述符,第二个就是我们所创建的sockaddr_in结构体,当然,我们传参数的时候我们需要进行强制类型转换,第3个参数是我们所设置结构体的长度,同时也是输入数据,也是输出数据。
当bind返回0表示绑定成功,返回-1表示绑定失败。
然后我们还是看一下他的内核调用:
然后就可以进入监听状态,函数listen()用来初始化服务器可连接队列,服务器统一时间仅能处理一个客户端连接,当多个客户端的连接请求同时到来的时候,需要排入等待队列,一个一个处理。
listen函数:
sockfd代表文件描述符,
backlog代表等待队列的长度,成功返回0,失败返回-1.
listen内核:
图片出自Linux网络编程。
然后当我们设置监听理由就是accept()等待连接,然后我们连接成功后就会返回一个新的套接字文件描述符来表示客户端的连接,客户端连接的信息可以由这个新的描述符获得,然后可以通过write和read来实现数据传送。
看一下accept()函数:
函数参数中,
sockfd是创建的一个socket,这个socket是和listen用同一个socket,因为是送监听处得到请求连接;
addr是用于描述请求连接一方的网络地址信息结构体的指针;
addrlen是上述结构体的大小;
函数成功会返回有效的接收到的socket描述符,失败返回-1并置错误码;
内核调用:
好了这就是我们的服务端,他连接建立。
下面我们来看一下客户端的编写,代码:
#include<stdio.h> #include<unistd.h> #include<string.h> #include<error.h> #include<sys/types.h> #include<sys/socket.h> #include<arpa/inet.h> #define _PORT_ 8888 void process_conn_client(int s) { ssize_t size = 0; char buffer[1024]; while(1) { size = read(0,buffer,1024); if(size > 0) { write(s,buffer,size); size = read(s,buffer,1024); write(1,buffer,size); } } } int main() { int s; struct sockaddr_in server_sock; s = socket(AF_INET,SOCK_STREAM,0); if(s < 0) { printf("sock error"); return 1; } bzero(&server_sock,sizeof(server_sock)); server_sock.sin_family = AF_INET; server_sock.sin_addr.s_addr = htonl(INADDR_ANY); server_sock.sin_port = htons(_PORT_); connect(s,(struct sockaddr *)&server_sock,sizeof(server_sock)); process_conn_client(s); close(s); return 0; }
对于客户端而言,我们只需要连接上我们的服务端就好了,所以只需要关注一个函数,connect函数,然后我们看一下connect()函数:
函数参数中,
sockfd是连接方创建的一个socket文件描述符;
addr因为是连接请求方,所以是远端要接收连接请求一方的网络socket地址信息;
addrlen是上述网络地址信息结构体的大小;
函数成功返回0,失败返回-1并置错误码;
他的内核实现:
对于客户端和服务端而言,我们的编程已近实现了,然后我们需要考虑的是几个关于服务端的性能问题:
在我所编写的代码中,我采用的是多进程的方式,每当我们建立连接后就fork一个子进程,子进程中进行数据传输,然后我们父进程就关闭accept所产生的文件描述符,子进程关闭不需要的监听的文件描述符。
在多进程中,我们需要注意到的是我们使用的是阻塞式的I/O模型,我们父进程中如果调用wait函数,waitpid函数进行等待总会比较浪费资源效率。
而且对于waitpid的非阻塞等待还存在一个问题,当我们需要释放子进程资源的时候,但是出现以下场景:
一个客户端连接进来,然后父进程进行非阻塞的等待,但是若以后都没有连接,将会卡在accept处,导致僵尸进程的产生。所以这种方式是存在问题的,所以有一种解决办法,就是利用子进程结束时返回的SIGCHLD信号来捕捉进行自定义的子进程资源释放,
还有一种方式就是多线程的编程,调用线程为分离状态。
pthread_t tid;//创建出一个线程 pthread_create(&tid, NULL, accept_fun, (void *)accept_sock); pthread_detach(tid);//将线程设置成分离状态,结束后不必等主线程回收资源
void* accept_fun(void *sock) { int accept_sock = (int)sock; char *buf[1024]; while(1) { memset(buf, ‘\0‘, sizeof(buf)); size_t size = read(accept_sock, buf, sizeof(buf)-1); if(size < 0) { perror("read"); break; } else if(size == 0) { printf("client is out...\n"); break; } else printf("client# %s\n", buf); } }
当然了,无论是多线程方式还是多进程方式,他总会出现性能上的低效率,因为一个线程/进程只能够处理一个连接的话是十分低效的,所以我们需要继续学习关于I/O的几种模型,使用多路复用状态的模型去进行服务器端的编写。