1 套接字
套接字是一种通信机制,通过使用套接字接口,一台机器上的进程可以和另一台机器上的进程进行网络通信,同一台机器之间的进程也可以互相通信。套接字明确把客户端和服务器区分开,可以实现将多个客户连接到一个服务器。
1.1 套接字连接原理
首先,服务器端应用程序用系统调用socket创建一个套接字,它是系统分配给服务器进程的类似文件描述符的资源,不能与其他进程共享。服务器进程给套接字起个名字,本地套接字的名字是Linux文件系统中的文件名,一般在/tmp
或/usr/tmp
中,网络套接字的名字是与客户连接的特定网络有关的服务标识符(端口号)。系统调用bind给套接字命名后,服务器进程就开始等待客户连接到这个命名套接字。系统调用listen创建一个队列,用于存放来自客户的进入连接,服务器用accept来接受客户的连接,此时会创建一个与原有命名套接字不同的新套接字,这个新套接字只用于与这个特定的客户进行通信,原有命名套接字继续处理来自其他客户的连接。其次,客户端调用socket创建一个未命名套接字,然后调用connect将服务器的命名套接字作为一个地址建立连接。连接建立后,我们可以像使用底层的文件描述符那样用套接字来实现双向的数据通信。
1.2 套接字属性
1.2.1套接字的域
域指定套接字通信中使用的网络介质。
AF_INET域指Internet网络,底层的协议——网际协议(IP)只有一个地址族,为IPv4协议。
AF_INET6域也指Internet网络,但底层使用IPv6协议。
AF_UNIX域是UNIX文件系统域,在一台未联网的计算机上也可以使用这个域,底层协议是文件的输入输出,地址就是文件名。
AF_ISO域是基于ISO标准协议的网络。
AF_XNS域是基于施乐(Xerox)网络。
服务器计算机上可能有多个服务在运行,服务器在特定的端口等待客户的连接。在系统内部,端口通过分配一个唯一的16位的整数来标识,而在系统外部,需要通过IP地址和端口号的组合来确定。知名的服务所分配的端口号在所有Linux和UNIX机器上都是相同的,小于1024的端口号都是为系统使用而保留的,系统文件/etc/services
列出了它们的端口号和提供的服务。如打印机缓冲队列进程(515),rlogin(513),ftp(21),Web服务器的标准端口httpd(80)等。
1.2.2 套接字类型和协议
在网络域AF_INET中,有两种通信机制,流(stream)和数据报(datagram)。
- 流套接字
由类型SOCK_STREAM指定,在AF_INET域中通过TCP/IP连接实现。它提供的是一个有序、可靠、双向字节流的连接,所以发送的数据可以确保不会丢失、复制或乱序到达。
TCP协议指传输控制协议(Transmission Control Protocol),提供排序、流控和重传,确保大数据传输的完整性。IP协议指网际协议(Internet Protocol),是针对数据包的底层协议,提供从一台计算机通过网络到达另一台计算机的路由。 - 数据报套接字
由类型SOCK_DGRAM指定,在AF_INET域中通过UDP/IP连接实现。提供的是一种无序的不可靠的服务,它并不建立和维持一个连接,对发送的数据报长度有限制,作为一个单独的网络消息被传输,可能会丢失、复制或乱序到达,但开销较小。
UDP协议指用户数据报协议(User Datagram Protocol),一般用于单次查询,并不保留连接信息。
1.3 套接字创建和连接
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain,int type,int protocol);
socket函数创建套接字,domain指定协议族(AF_UNIX,AF_INET),type指定套接字的通信类型(SOCK_STREAM,SOCK_DGRAM),protocol一般设为0,使用默认协议。返回一个描述符,类似底层文件描述符。成功建立连接后用read和write系统调用接收和发送数据。
#include <sys/socket.h>
int bind(int socket,const struct sockaddr* address,size_t address_len);
#include <sys/un.h>
struct sockaddr_un
{
sa_family_t sun_family; /*AF_UNIX*/
char* sun_path; /*pathname*/
};
#include <netinet/in.h>
struct sockaddr_in
{
short int sin_family; /*AF_INET*/
unsigned short int sin_port; /*port number*/
struct in_addr sin_addr; /*internet address*/
};
struct in_addr{unsigned long int s_addr};
bind函数用于给套接字命名,服务器调用它使socket与特定的地址address相关联。这样AF_UNIX就会关联到一个文件系统的路径名,AF_INET会关联到一个IP地址和端口号。address有两种地址格式AF_UNIX域的sockaddr_un和sockaddr_in,我们要在这里把它强转为struct sockaddr*的类型。参数address_len指明地址结构的长度。调用成功时返回0,失败时返回-1。
#include <sys/socket.h>
int listen(int socket,int backlog);
服务器利用listen系统调用创建一个队列来保存未处理的请求。参数backlog指出设置的队列长度,当待处理的连接个数超过这个数字时,再往后的连接将被拒绝,客户的连接会失败。成功时返回0,失败时返回-1。
#include <sys/socket.h>
int accept(int socket,struct sockaddr* address,size_t* address_len);
//非阻塞模式的设置方法
int flag = fcntl(socket,F_GETFL,0);
fcntl(socket,F_SETFL,O_NONBLOCK | flags);
服务器利用accept系统调用来等待客户建立对该套接字的连接。当客户程序试图连接到该套接字上时才返回,如果套接字队列中没有未处理的连接,它将阻塞直到有客户建立连接(可用O_NONBLOCK来改变这一行为)。返回一个新套接字描述符来与客户进行通信。连接客户的地址被放到address参数指向的sockaddr结构中,address_len参数指定预期的地址长度,返回时它被设置为地址结构的实际长度。失败时返回-1。
#include <sys/socket.h>
int connect(int socket,const struct sockaddr* address,size_t address_len);
客户程序用connect系统调用通过在一个未命名套接字和服务器监听套接字之间建立连接的方法来连接到服务器。参数socket指定客户端套接字,连接到参数address指定的服务器套接字,address_len指定address的长度。成功时返回0,失败时返回-1。连接如果不能立刻建立,connect会被阻塞一段不确定的超时时间。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
in_addr_t inet_addr(const char* cp);
char* inet_ntoa(struct in_addr in);
int inet_aton(const char* cp,struct in_addr* inp);
inet_addr函数把cp指向的网络IPv4地址转换为网络字节序的二机制数据。
inet_ntoa把in这个网络字节序二进制数据转换为IPv4地址。
inet_aton把cp指向的网络IPv4地址转换为网络字节序,并存储在inp指向的单元中。
1.4 在本机用套接字实现网络通信
UNIX计算机配置了一个只包含它自身的回路(loopback)网络,网络中只有一台计算机localhost,它有一个标准的IP地址127.0.0.1。在/etc/hosts
中,还列出了在共享网络中的其它主机的名字和对应的地址。
//server2.c
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>
#include <stdilb.h>
int main()
{
int server_sockfd,client_sockfd;
int server_len,client_len;
struct sockaddr_in server_address;
struct sockaddr_in client_address;
server_sockfd = socket(AF_INET,SOCK_STREAM,0);
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = inet_addr("127.0.0.1");//服务器的地址
server_address.sin_port = 9734;//服务器监听的端口号
server_len = sizeof(server_address);
bind(server_sockfd,(struct sockaddr*)(&server_address),server_len);//server_sockfd是服务器监听套接字
listen(server_sockfd,5);
while(1)
{
char ch;
printf("server waiting\n");
client_len = sizeof(client_address);
client_sockfd = accept(server_sockfd,(struct sockaddr*)(&client_address),&client_len);//client_address中保存的是连接客户的地址信息,返回套接字client_sockfd用于与这个连接客户数据通信
read(client_sockfd,&ch,1);
ch++;
write(client_sockfd,&ch,1);
close(client_sockfd);
}
}
//client2.c
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>
#include <netinet/in.h>
#include <stdlib.h>
int main()
{
struct sockaddr_in address;
char ch = 'A';
int sockfd = socket(AF_INET,SOCK_STREAM,0);
address.sin_family = AF_INET;
address.sin_addr.s_addr = inet_addr("127.0.0.1");//想要连接的服务器的地址
address.sin_port = 9734;//想要连接的服务器的监听端口
int len = sizeof(address);
int result = connect(sockfd,(struct sockaddr*)(&address),len);//将sockfd客户端套接字与服务器的地址端口连接
if(result == -1)
{
perror("oops: client2");
exit(1);
}
write(sockfd,&ch,1);
read(sockfd,&ch,1);
printf("char from server = %c\n",ch);
close(sockfd);
exit(0);
}
netstat -a
命令用于查看网络连接状态。
1.5 网络字节序问题
由于服务器设置的端口为1574,但我们设置的端口为9734,为什么不一样?这是网络字节序和主机字节序不同的原因。为了使不同类型的计算机可以就通过网络传输的多字节整数的值达成一致,我们要在客户和服务器程序传输数据之前,把它们内部的整数表示方式转换为网络字节序。
#include <netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);
为了保证16位的端口号有正确的字节序,我们要用这些函数转换端口地址。
INADDR_ANY允许到达服务器任一端口的连接。
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
server_address.sin_port = htons(9734);
2 网络信息获取
主机网络数据库函数给出了与主机网络相关联的一些数据。
#include <netdb.h>
struct hostent
{
char* h_name; /*name of the host*/
char** h_aliases; /*nicknames */
int h_addrtype; /*address type AF_INET??*/
int h_length; /*length of address in bytes*/
char** h_addr_list; /*list of address(network older)*/
};
struct hostent* gethostbyname(const char* name);
struct hostent* gethostbyaddr(const void* addr,size_t len,int type);
struct servent
{
char* s_name; /*name of the service*/
char** s_aliases;/*aliases*/
int s_port; /*ip port number*/
char** s_proto; /*service type,usually tcp or udp*/
};
struct servent* getservbyname(const char* name,const char* proto);
struct servent* getservbyport(int port,const char* proto);
getservbyname与getservbyport函数获取服务及其关联端口号的有关信息。
gethostbyname与gethostbyaddr函数主机数据库信息。
#include <unistd.h>
int gethostname(char* name,int namelength);
gethostname函数获取主机名字写入name,主机名以null结尾,参数namelength指定字符串长度。返回主机名太长时,它会被截断。
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
char *host;
char myname[256];
if(argc == 1)
{
gethostname(myname, 255);//未指定主机名则用gethostname获取主机名
host = myname;
}
else
host = argv[1];
struct hostent* hostinfo = gethostbyname(host);//通过主机名获取主机信息
if(!hostinfo)
{
fprintf(stderr, "cannot get info for host: %s\n", host);
exit(1);
}
printf("results for host %s:\n", host);
printf("Name: %s\n", hostinfo -> h_name);
printf("Aliases:");
char** names = hostinfo -> h_aliases;
while(*names)
{
printf(" %s", *names);
names++;
}
printf("\n");
if(hostinfo -> h_addrtype != AF_INET)
{
fprintf(stderr, "not an IP host!\n");
exit(1);
}
char** addrs = hostinfo -> h_addr_list;//主机的地址列表,网络字节序
while(*addrs)
{
printf(" %s", inet_ntoa(*(struct in_addr *)*addrs));
addrs++;
}
printf("\n");
exit(0);
}
3 多客户连接到服务器时的通信
3.1 fork创建子进程实现
当多个客户同时连接至同一个服务器时,服务器会为每个客户连接创建一个套接字,服务器的监听套接字会继续监听指定的端口和地址。如果服务器不能立刻接受后来的连接,它们将被放到队列中以等待处理。
- 我们可以在服务器程序调用fork函数创建子进程,每有一个客户连接到来就创建一个子进程。打开的套接字可以被新的子进程继承,新的子进程可以和连接的客户进行通信,而主服务器进程可以继续接受以后的客户连接。
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
int server_sockfd = socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in server_address;
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = htonl(INADDR_ANY);//服务器允许任意ip地址连接
server_address.sin_port = htons(9734);//服务器监听端口
int server_len = sizeof(server_address);
bind(server_sockfd ,(struct sockaddr*)(&server_address),server_len);//设置监听套接字server_sockfd
listen(server_sockfd,5);//设置监听套接字server_sockfd的未处理队列长度
signal(SIGCHLD,SIG_IGN);//子进程停止或退出时向主进程发送SIGCHLD信号,设置处理函数SIG_IGN,即忽略。SIGCHLD信号默认就是忽略的
int client_sockfd;
int client_len;
struct sockaddr_in client_address;
//保持监听
while(1)
{
char ch;
printf("server waiting\n");
client_len = sizeof(client_address);
client_sockfd = accept(server_sockfd,(struct sockaddr*)(&client_address),&client_len);//接受来自用户的连接,获取用户地址保存在client_address中,返回通信套接字client_sockfd与该用户进行通信,有客户试图连接时才返回。
if(fork() == 0)//创建子进程
{
//如果在子进程,与用户进行通信
read(client_sockfd,&ch,1);
sleep(5);
ch++;
write(client_sockfd,&ch,1);
close(client_sockfd);//结束与子进程的通信
}
else
{
//如果在父进程中,继续保持监听状态,在这里监听状态的实现通过上面while(1),在这里什么也不用做
//父进程只需关闭用户套接字client_sockfd
close(client_sockfd);
}
}
}
本程序用fork来处理多个客户,但在数据库应用中这并不是最佳的方案。服务器程序很大,并在访问方面还存在需要协调多个服务器副本的问题。我们真正要的,是服务器在不阻塞、不等待客户请求到达的前提下处理多个客户。
3.2 select系统调用
select系统调用允许程序同时在多个底层文件描述符上等待输入输出,类似地服务器也可以通过同时在多个打开的套接字上等待请求的到来来处理多个客户。
#include <sys/types.h>
#include <sys/time.h>
void FD_ZERO(fd_set* fdset);
void FD_CLR(int fd,fd_set* fdset);
void FD_SET(int fd,fd_set* fdset);
int FD_ISSET(int fd,fd_set* fdset);
struct timeval
{
time_t tv_sec; /*seconds*/
long tv_usec; /*useconds*/
}
int select(int nfds,fd_set* readfds,fd_set* writefds,fd_set* errorfds,struct timeval* timeout);
数据结构fd_set是由打开的文件描述符构成的集合,FD_SETSIZE指定它能容纳的最大文件描述符数目。
FD_ZERO把fdset初始化为空集合。
FD_CLR和FD_SET清除或设置fdset指向的文件描述符集合的fd项。
FD_ISSET判断fdset指向的集合中是否有fd,若有则返回非零值。
select的参数nfds指定需要测试的文件描述符数目,3个描述符集合可被设置为空指针,表示不执行相应的测试。当readfds中有描述符可读、或writefds中有描述符可写、或errorfds中有描述符遇到错误条件、或阻塞timeout指定的超时时间后返回,返回状态发生变化的描述符总数,返回后描述符集合将被修改以指示哪些描述符处于可读可写或错误状态,失败时返回-1并设置errno。如果timeout为空指针,并且套接字上也没有任何活动,则会一直阻塞下去。
#include <sys/types.h>
#include <sys/time.h>
#include <stdio.h>
#include <sys/ioctl.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
fd_set inputs;
char buff[128];
struct timeval timeout;
FD_ZERS(&inputs);
FD_SET(0,&inputs);//将标准输入加入fd_set
while(1)
{
int nread;
fd_set testfds = inputs;
timeout.tv_sec = 2;
timeout.tv_usec = 500000;//2.5s
int result = select(FD_SETSIZE,&testfds,(fd_set*)NULL,(fd_set*)NULL,&timeout);//阻塞直到testfds中文件描述符有数据到来或超时
switch(result)
{
case 0://超时
printf("timeout\n");
break;
case -1://error
perror("select error");
exit(1);
default:
if(FD_ISSET(0,&testfds))//返回后描述符被修改以指示哪些fds可读,如果testfds中有0,即stdin可读
{
ioctl(0,FIONREAD,&nread);//获取stdin的缓冲区中可读的字节数
if(nread == 0)//fd变化,但没有数据,EOF
{
printf("no data can read\n")
exit(0);
}
nread = read(0,buff,nread);
buff[nread] = '\0';
printf("read %d from keyboard: %s",nread,buff);
}
break;
}
}
}
- 用select系统调用来同时处理多个客户就无需再依赖于子进程了。服务器用select系统调用来同时检查监听套接字和客户的连接套接字,一旦select返回就用FD_ISSET来遍历所有可能的文件描述符,检查出哪个套接字的活动发生。
- 如果是监听套接字可读,则说明有一个新的客户试图连接服务器,此时调用accept而不会发生阻塞;如果某个客户描述符可读,说明对应的客户有请求需要我们处理,如果读操作返回零字节,表示客户进程已经结束,服务器可以关闭套接字并把它删除。
//server5.c
#include <sys/types.h>
#include <sys/time.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
struct sockaddr_in server_address;
int server_sockfd = socket(AF_INET,SOCK_STREAM,0);//创建服务器监听套接字
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
server_address.sin_port = htons(9734);
int server_len = sizeof(server_address);
bind(server_sockfd,(struct sockaddr*)(&server_address),server_len);//监听套接字绑定连接客户的ip地址,并在指定的端口监听
fd_set readfds;
listen(server_sockfd,5);//创建连接队列
FD_ZERO(&readfds);
FD_SET(server_sockfd,&readfds);
while(1)
{
fd_set testfds = readfds;
printf("server waiting in select\n");
int result = select(FD_SETSIZE,&testfds,(fd_SET*)NULL,(fd_SET*)NULL,(struct timeval*)NULL);//同时监听testfds中的所有套接字,有套接字可读时select返回
if(result < 1)
{
printf("select error");
exit(1);
}
//遍历套接字,看看哪个在testfds中,它就是发生变化而可读的那个socket
for(int fd = 0;fd < FD_SETSIZE;fd++)
{
if(FD_ISSET(fd,&testfds))//获取发生活动的fd
{
if(fd == server_sockfd)//server_sockfd可读,即有新客户连接请求到来
{
struct sockaddr_in client_address;
int client_len;
int client_sockfd = accept(server_sockfd,(struct sockaddr*)(&client_address),&client_len);//accept处理客户请求,返回与客户通信的套接字
FD_SET(client_sockfd,&readfds);//加入fd_set中
printf("new client come ,client_sockfd = %d\n",client_sockfd);
}
else//客户活动,请求数据通信
{
int nread;
ioctl(fd,FIONREAD,&nread);//获取fd的缓冲区中可读的字节数
if(nread == 0)//没有可读字节但发生了活动,即用户端关闭,客户端随即断开与用户的连接
{
close(fd);
FD_CLR(fd,&readfds);//从fd_set中清除该fd
printf("client close ,remove connect fd = %d\n",fd);
}
else//有数据可读,与客户通信
{
char ch;
read(fd,&ch,1);
sleep(5);
printf("server client on fd = %d\n",fd);
ch++;
write(fd,&ch,1);
}
}
}
}
}
}
- 在实际应用中,我们可以用一个变量来保存已连接的套接字的最大文件描述符符号(它不一定是最新连接的套接字文件描述符符号),可以避免对数千个未连接的套接字的检查。
4 数据报通信UDP
前面我们使用面向连接的TCP套接字完成通信,但在有些情况下,在程序中花费时间建立和维持一个套接字连接是不必要的。当客户需要发送一个短小的查询请求给服务器,并且希望收到一个短小的响应时,我们一般使用由UDP提供的服务。由于UDP提供的是不可靠服务,我们必须检查错误并在必要时重传,实际上UDP在局域网中是很可靠的。
#include <sys/types.h>
#include <sys/socket.h>
int sendto(int sockfd,void* buff,size_t len,int flags,struct sockaddr* to,socklen_t tolen);
int recvfrom(int sockfd,void* buff,size_t len,int flags,struct sockaddr* from,socklen_t* fromlen);
sendto系统调用从buff缓冲区中给使用指定套接字地址to的目标服务器发送一个数据报,参数tolen指定目标服务器地址的长度。
recvfrom系统调用在套接字sockfd上等待从特定地址from到来的数据报,参数fromlen获取目标服务器地址的长度。
正常应用中,参数flags设为0。发生错误时,返回-1并设置errno。
注意:除非用fcntl将套接字设置为非阻塞方式,否则recvfrom调用将一直阻塞,我们也可以用select调用和超时设置来判断是否有数据到达套接字。
//getdate-udp.c
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc,char* argv[])
{
char* host;
char buff[128];
if(argc == 1)
host = "localhost";
else
host = argv[1];
struct hostent* hostinfo = gethostbyname(host);//通过主机名获取主机信息
if(!hostinfo){
fprintf(stderr,"no host: %s\n",host);
exit(1);
}
struct servent* servinfo = getservbyname("daytime","udp");//通过服务名获取服务信息
if(!servinfo){
fprintf(stderr,"no daytime service\n");
exit(1);
}
printf("daytime port is %d\n",ntohs(servinfo->s_port));
int sockfd = socket(AF_INET,SOCK_DGRAM,0);
struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_port = servinfo->s_port;//绑定服务器端口号
address.sin_addr = *(struct in_addr*)(*hostinfo->h_addr_list);//绑定服务器地址,在这里是本机,所以实际是本机的地址
int len = sizeof(address);
//sockfd不需bind,在发送或接收数据时指定地址
int result = sendto(sockfd,buff,1,0,(struct sockaddr*)(&address),len);
result = recvfrom(sockfd,buff,sizeof(buff),0,(struct sockaddr*)(&address),&len);
buff[result] = '\0';
close(sockfd);
exit(0);
}