4 LINUX 网络编程
网络程序设计全靠套接字接收和发送信息,尽管套接字这个词好像有点神秘,
其实这个概念极其容易理解。
4.2 SOCKET
我们可以给你一个初步的构建一个连接的工作流程,以下谈到的函数将会在流程图中一一出现,如图4-1所示:
图4-1 socket工作流程图
4.2.1 socket简介
80年代早期,远景研究规划局赞助了一个研究组,让他们将TCP/IP软件移植到UNIX操作系统中,并将结果提供给其他网点。作为项目的一部分,设计者们创建了一个接口,应用进程使用这个接口可以方便的通信,对于那些不能方便的融入已有的函数集的情况,就在增加新的系统调用以支持TCP/IP功能。
于是插口接口出现了(Berkeley Socket),这个系统被称为Berkeley Unix 或者BSD UNIX。
4.2.2 socket功能
理解socket,socket 英文原意是 “孔”或“插座”,作为 BSD UNIX 的 通讯机制,取后一种意思。Socket 实质上提供了进程通信的端点。进程通信之前,双方必须各自创建一个端点,否则是没有办法建立联系并相互通信的。
两个设备之间的数据交换可以描述为报文从某一个设备上的套接字发送到另一个设备上的套接字,两个套接字建立一个关联,该关联应该包含以下元素(协议,本地地址,本地端口,远端地址,远端端口) 可以建立多个socket,以允许几个应用共享同一个设备IP地址。另外,在一个设备上可以存在多个关联,这意味着一个设备可以同时与其他设备有多个连接。
最重要的是,socket是面向 客户-服务器 模型而设计的,针对客户和服务器程序提供不同的socket供系统调用。Socket利用客户-服务器模式巧妙的解决了进程之间建立通信连接的问题。服务器socket为全局所公认非常重要。
4.2.3 socket 类型
套接字有三种类型: 流式套接字(SOCK_STREAM)
数据报套接字(SOCK_DGRAM)
原始套接字
4.2.4 socket 地址
struct sockaddr
这个结构体用来存储套接字地址,或者说它是存储一个套接字信息的
struct sockaddr{
unsigned short sa_family; /* 地址族, AF_xxx */
char sa_data[14]; /* 14 字节的协议地址 */
};
sa_family一般为AF_INET,代表Internet(TCP/IP)地址族;sa_data则包含该socket的IP地址和端口号。
另外还有一种结构类型: struct sockaddr_in
struct sockaddr_in {
short int sin_family; /* 地址族 */
unsigned short int sin_port; /* 端口号 */
struct in_addr sin_addr; /*IP地址 */
unsigned char sin_zero[8]; /*填充0与struct sockaddr同样大小 */
这个结构更方便使用。sin_zero用来将sockaddr_in结构填充到与struct sockaddr同样的长度,可以用bzero()或memset()函数将其置为零。指向sockaddr_in 的指针和指向sockaddr的指针可以相互转换,这意味着如果一个函数所需参数类型是sockaddr时,你可以在函数调用的时候将一个指向 sockaddr_in的指针转换为指向sockaddr的指针;或者相反。
struct in_addr
struct in_addr{
unsigned long s_addr;
};
如果你声明了一个 ina 作为一个 struct sockaddr_in 的结构,那么ina.sin_addr.s_addr就是一个4字节的IP地址(按网络字节顺序排放)。
计算机数据存储有两种字节优先顺序:高位字节优先和低位字节优先。Internet上数据以高位字节优先顺序在网络上传输,所以对于在内部是以低位字节优先方式存储数据的机器,在Internet上传输数据时就需要进行转换,否则就会出现数据不一致。下面是几个字节顺序转换函数:
·htonl():把32位值从主机字节序转换成网络字节序
·htons():把16位值从主机字节序转换成网络字节序
·ntohl():把32位值从网络字节序转换成主机字节序
·ntohs():把16位值从网络字节序转换成主机字节序
4.2.5 socket 函数
u socket()
u bind()
u listen()
u connect()
u accept()
u send()
u recv()
u sendto()
u recvfrom()
u close()
u shutdown()
u setsockopt()
u getsockopt()
u gethostbyname()
u gethostbyaddr()
u fcntl()
Socket 里面有这么多的函数,我在这不一一讲述,讲述几个比较重要的函数的调用。
首先你想获得套接字的话请调用 socket()函数,你可以这样写
#include<sys/types.h>
#include<sys/socket.h>
int main()
{
int sockfd;
sockfd = socket(int domain,int type,int protocol);
return 0;
}
我们要调用一个函数首先应该对它的参数非常熟悉,在这里domain 一般被设置为 AF_INET, type 参数告诉了内核这个socket是什么类型的, SOCK_STREAM或者 SOCK_DGRAM,最后protocol只需设置为0。
套接字创建函数socket()的返回值是一个你以后可以使用的套接字描述符。如果发生错误,它返回 -1 ,全局变量error 将被设置为错误代码。
使用socket函数创建了一个套接字之后你就可以使用它么?不是的,你需要绑定它,就好像你买了一部手机仍然要去办一张电话卡一样。bind()函数可以帮助你实现同计算机端口绑定的功能。
一般来说,你可以用来绑定的端口号是1024 - 65535,前面说过0 - 1023号是知名端口,这些端口一般固定分配一些服务,用户无权使用它们。
你想调用bind()函数,首先熟悉它的参数,其次是它的返回值,下面一段代码可以帮助你了解它。
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
int main()
{
int sockfd;
sockfd = socket(int domain,int type,int protocol); /* 已经创建好的一个套接字*/
/*进入绑定阶段*/
struct sockaddr_in myaddr;
myaddr.sin_family = AF_INET;
myaddr.sin_port = htons(8000); /* 你需要绑定的端口号 */
myaddr.sin_addr.s_addr = inet_addr("192.168.2.68");/*本机IP */
bzero(&(myaddr.sin_zero),8);
bind(sockfd,(struct sockaddr*)&myaddr,sizeof(struct sockaddr));
/*你需要在这里对BIND返回值进行判断!*/
..... ....
...... .....
return 0;
}
Socket相关的函数一般都有一个返回值,在实际的代码中我都会对返回值做一个判断,这样在调试程序时会节省很多时间。
上面的代码中依然有的值得注意的地方:myaddr.sin_port myaddr.sin_addr.s_addr是网络字节顺序。
最后,bind()可以在程序中自动获取你自己的IP。
myaddr.sin_port = 0 ; /* 随即选择一个端口 */
myaddr.sin_addr.s_addr = INADDR_ANY; /* 使用自己的地址 */
如上,通过设置myaddr.sin_port 为0,bind()可以知道你要它帮你选择合适的端口。通过设置myaddr.sin_addr.s_addr为 INADDR_ANY,bind()知道你要它将s_addr填充为运行这个进程的机器的IP。这一切都可以要求bind()来自动帮助你完成。
其实还有更加严谨的做法。可以保证你的程序在编译时不会出问题。
myaddr.sin_port = htons(0) ; /* 随即选择一个未用端口 */
myaddr.sin_addr.s_addr = htonl(INADDR_ANY); /* 使用自己的IP */
listen()函数使你创建并且绑定的套接字处于一个监听的状态。
这个函数很好理解 声明如下:
#include<sys/socket.h>
int listen(int sockfd,int backlog);
按照惯例,去了解它的参数以及返回值。
sockfd 就是你用socket()创建出来的socket描述符。
backlog 是代表你能监听的队列容纳的最大数目。
返回值 -1 代表监听失败。
如果有人创建了一个套接字(socket()),绑定了它(bind()),并让它处于监听(listen()),你想去连接它,这时connect()函数可以帮你实现。
有关connect()函数的定义是这样的:
#include<sys/socket.h>
#include<sys/types.h>
int connect(int sockfd,struct sockaddr* servaddr,int addrlen);
第一个参数并不是你要去连接的socket,而是本地创建的用于连接服务器端 的socket,也就是说,客户端想去连接服务端的话必须自己也有一个socket.
第二个是我们前面介绍过的结构体,它存储了socket 的地址。很显然,你必须知道对方socket的地址你才能去发出连接请求。
第三个参数 大小为 sizeof(struct sockaddr)。
accept()函数听名字就知道它有什么用,不错,你connect()我,我accept()你。
我们先来谈谈它的返回值,它的返回值是一个 新的socket,这个socket代表了你我之间的连接,你想发送数据给我就是通过这个新的socket。失败则返回 -1。
它的声明: #include<sys/socket.h>
int accept(int sockfd,void *addr, int addrlen);
sockfd 是一个正在处于监听状态的套接字。
addr 一般是一个指向struct sockaddr_in 结构的指针,里面存储的是远程连接过来的计算机信息。
addrlen 大小为 sizeof(struct sockaddr_in)。
好的,socket有关的函数介绍到这里我们应该可以来创建一个连接了。
再介绍两个很好的函数 send() 和recv()。
它两都是通过连接的套接字进行通讯的函数。
函数声明: #include<sys/types.h>
#include<sys/socket.h>
int send(int sockfd,void* msg,int len,int flags);
int recv(int sockfd,void* buf, int len,int flags);
两个函数的参数都差不多,第一个就是本地已经建立了连接的套接字描述符,你的所有操作都对对它而言的!
第二个参数就是你发送的数据的地址(存放接受的数据存储的本地缓冲区的指针)
第三个参数就是你发送的数据的长度(本地缓冲区的大小)
第四个参数是一个标志,一般都为0
别忘了返回值,返回的是发送(接收)的字节数,几乎所有的函数只要调用失败或者出错都会返回 -1。
4.2.6 面向连接的socket 连接实例
服务器的工作流程是这样的:
ü 首先调用socket函数创建一个Socket
ü 然后调用bind函数将其与本机地址以及一个本地端口号绑定
ü 然后调用 listen在相应的socket上监听
ü 当accpet接收到连接服务请求时,将生成一个新的socket。
ü 服务器显示该客户机的IP地址,并通过新的socket向客户端发送字符串
ü 最后关闭该socket
客户端的工作流程是这样的:
ü 客户端程序首先通过服务器域名获得服务器的IP地址
ü 然后创建一个socket
ü 调用connect函数与服务器建立连接
ü 连接成功之后接收从服务器发送过来的数据
ü 最后关闭socket
我们前面提到的send和recv 函数是用于由连接的套接字的数据通讯传输,那么在无连接的UDP 通讯时应该用什么函数呢?
sendto() 和 recvfrom()
在了解过send 和 recv函数之后它们也不难理解,数据报套接字无法与远程主机取得连接,我们要发送数据应该知道些什么呢?不错,是远程主机的IP 和端口!
下面是两个函数的声明:
#include<sys/types.h>
#include<sys/socket.h>
int sendto(int sockfd,const void *msg,int len,unsigned int flags,const struct sockaddr *to,int tolen);
int recvfrom(int sockfd,const void* buf,int len,unsigned int flags, const struct sockaddr*from,int fromlen);
两个函数的参数已经不用再去解释了,跟send(),recv()函数非常的相似
当程序完成了网络传输后,你需要关闭这个套接字描述符所代表的连接,实现它非常简单,调用close()函数:close(sockfd);
执行了close()之后,套接字将不允许进行读写操作,任何有关对套接字描述符进行读写的操作都会接收到一个错误。
如果你想做的细致一点,你可以调用shutdown()函数
shutdown()的声明:
#include<sys/socket.h>
int shutdown(int sockfd,int how);
how参数可以取下面的值:0 代表不允许以后数据的接收操作,
1代表不允许以后数据的发送操作,
2不允许以后的任何操作,完全关闭。
shutdown()执行成功返回0,错误返回 -1。
掌握了这么多的函数是不是就能写成一个聊天室呢?其时不然,我们忽略了一个重要的细节,就是当我们在程序中调用了recv()函数等待对方发送信息,可是对方却一直没有发送,此时我们这边的程序就一直处于一个等待状态,不能做别的事情了。这样的情况我们称之为“进程阻塞”,那么我们如何来解决这个问题?
5.1 并发服务器
并发服务器有这么几种分类
按连接类型分类: 面向无连接的服务器 TCP
面向有连接的服务器 UDP
按处理方式分类: 迭代服务器
并发服务器
5.2 进程
进程简述
对应用程序来说,进程就像一个大容器。在应用程序被运行后,就相当于将应用程序装进容器里了,你可以往容器里加其他东西(如:应用程序在运行时所需的变量数据、需要引用的DLL文件等),当应用程序被运行两次时,容器里的东西并不会被倒掉,系统会找一个新的进程容器来容纳它。
进程的概念
是60年代初首先由麻省理工学院的MULTICS系统和IBM公司的CTSS/360系统引入的。进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。
进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它
是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时,它才能成为一个活动的实体,我们称其为进程。进程是操作系统中最基本、重要的概念。是多道程序系统出现后,为了刻画系统内部出现的动态情况,描述系统内部各道程序的活动规律引进的一个概念,所有多道程序设计操作系统都建立在进程的基础上。
进程的特征
动态性:进程的实质是程序在多道程序系统中的一次执行过程,进程是动态产生消亡的。
并发性:任何进程都可以同其他进程一起并发执行。
独立性:进程是一个能独立运行的基本单位,同时也是系统分配资源和调度的独立单位。
异步性:由于进程间的相互制约,使进程具有执行的间断性,即进程按各自独立的、不可预知的速度向前推进。
结构特征:进程由程序、数据和进程控制块三部分组成,多个不同的进程可以包含相同的程序:一个程序在不同的数据集里就构成不同的进程,能得到不同的结果;但是执行过程中,程序不能发生改变。
创建进程
通常有两种方法可以创建进程。我们这里介绍一种,那就是使用fork和exec。
一个进程调用fork会创建一个被称为子进程的副本进程。父进程从调用fork的
地方继续执行,子进程也一样。我们先来介绍fork函数:
头文件 #include <unistd.h> #include<sys/types.h>
函数定义:pid_t fork( void ); (pid_t 是一个宏定义,其实质是int 被定义在#include<sys/types.h>中)
返回值: 若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。
函数说明:一个现有进程可以调用fork函数创建一个新进程。由fork创建的新进程被称为子进程(child process)。fork函数被调用一次但返回两次。两次返回的唯一区别是子进程中返回0值而父进程中返回子进程ID。子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。注意,子进程持有的是上述存储空间的“副本”,这意味着父子进程间不共享这些存储空间,它们之间共享的存储空间只有代码段。
exec族函数:
功 能:装入并运行其它程序的函数 。
函数说明: fork()创建了一个程序,但是如果这个程序只能局限在自身的代码段范围内,不能去执行别的程序,那么fork也没有实际的意义,所以需要使用exec函数调用,用于从一个进程的地址空间中执行另外一个进程,覆盖自己的地址空间,有了这个调用,就可以使用fork+exec执行别的用户程序了。一个进程使用exec执行后,代码段、数据段、bss段和堆栈都被新程序覆盖,唯一保留的是进程号。
图5-1 多进程并发服务器状态图 accept函数调用前
图5-2 多进程并发服务器状态图 accept函数调用后
图5-3 多进程并发服务器状态图 调用fork函数后
图5-4 多进程并发服务器状态图(父进程关闭连接套接字,子进程关
闭监听套接字)
多进程服务器的问题:首先fork占用大量的资源,内存映像要从父进程中拷贝到子进程,所有描述要在子进程中复制。其次fork子进程后,需要用进程间通信在父子进程中传递信息。子进程返回信息给父进程需要做很多工作。
5.3 I/O复用并发服务器
I/O复用技术是为了解决进程或线程阻塞到某个I/O系统调用而出现的技术,使进程不阻塞于某个特定的I/O系统调用。它也可用于并发服务器的设计。但很多情况下它是与多线程和多进程一起使用。以下是I/O用的一个程序:
fd_set infds;
for(;;)
{
FD_ZERO(&infds);
FD_SET(fileno(stdin),&infds);
FD_SET(sockfd,&infds);
maxfd = max(fileno(stdin),sockfd)+1;
if(select(maxfd,&infds,NULL,NULL,NULL)==-1)
{/* 错误处理 */ }
if(FD_ISSET(sockfd,&infds))
{/* 读SOCKET */ }
If(FD_ISSET(fileno(stdin),&infds))
{/* 读标准输入 */ }
}
}
此程序阻塞于select,而不是真正的I/O系统调用,select的第二个参数是读描述符集。在这个描述符集中是一些套接口描述子和标准输入描述字。如果描述符集中没有一个描述符可读,则进程阻塞,直到有一个描述符可读或超时才返回。返回后,要判断是哪一个描述符可读,依次进行处理。Select的第一个参数是最大描述符加一的值。第三和第四个参数是写描述符集和异常描述符集。
如果网络服务器程序,每个套接字描述符应置入可读描述符集中,这样只要客户有连接,select就可返回,在处理客户请求。
这种方法的优点是:由于只有一个进程或线程,系统资源消耗较小。I/O复用技术的应用十分广泛,总结一下主要有一下几个方面:
1)客户程序需要同时处理交互式的输入以及服务器之间的网络连接。
2)客户端需要同时对多个网络连接做出反应。
3)TCP服务器需要同时处理处于监听状态和多个连接状态的套接字。
4)服务器需要处理多个网络协议套接字。
5)服务器需要同时处理不同的网络服务和协议。
通过讲述多进程,I/O复用服务器编程技术,分析了它们的优缺点。多进程服务器和多线程服务器是目前服务器设计的主流技术。预先派生子进程或新线程的服务器性能比起传统的服务器性能要优越。I/O复用是用一个进程处理所有的客户请求,免去了创建进程和线程的开销,应用场合也十分广泛。一个好的网络并发服务器,要根据业务需求选择合适的设计方法。
5.4 聊天室的设计
本次设计中采用的是多进程设计的方式创建一个聊天室的服务端。至于select函数和多线程的方式都可以,这里没有采用。聊天室不仅仅是一个聊天的地方,客户端可以查看服务端的文件并且按照需求进行下载,或者上传一些文件到服务端。
服务端所在的目录里有一个名为test.txt的文件,客户端想下载它,于是输入相应的指令,实现了文件从服务器到客户的传输。
首先服务器打开,等待客户的连接。
然后客户端输入主机的IP地址以及端口号进行连接。
连接成功后输入指令查看服务器的文件。
选择test.txt进行下载。
客户端下载文件成功, 如图5-5所示:
图5-5 下载文件功能
同样我们也可以进行文件的上传,客户端拥有一个test2.txt的文件,并且想将他上传到服务器:
首先服务端打开并等待客户端的连接。
客户输入主机IP号以及端口号进行连接。
连接成功后输入指令上传相应的文件,上传了test2.txt。
主机接收文件成功, 如图5-6所示:
图5-6 上传文件功能
然后就是聊天室的主要功能了,客户端之间的通信,有两个客户端同时在与主机连接,然后输入指令进入聊天室,下图给出了一个简单的情况,2个访问者都做了一个自我的介绍。同时主机将对话传输到另一个客户端去, 如图5-7所示:
图5-7 聊天功能