这里线程模型是指winsock相关的线程模型设计。
在本软件的设计的过程中有些问题是涉及到winsock的问题,为了能够很好的设计线程模型,必须理解清楚socket的内部工作机制。为此,首先从外面开始分析。
一、为什么使用多线程
1、使用多线程是为了避免应用程序主界面在I/O操作中没有反应,出现假死机现象。
Socket是一种特殊的I/O,所以很可能会出现这种现象。例如发送数据,或者连接服务器的时候。
2、为了提高cpu利用率(在多cpu环境)和改善应用程序的并发性能。
在多cpu环境,几个线程可以同时在不同的cpu上执行,从而提高了应用程序的效率。另外,应用程序有时候需要并发(包括单个cpu环境下的轮流执行)才能使得应用程序的行为比较流畅和连贯。例如收报,发报,报文处理三个工作如果交给一个线程完成,可能会造成报文处理的时候收报或者发报不能继续的结果。
二、多线程带来的问题
因为socket是I/O,所以,多个线程操作同一个I/O将会引发复杂的同步和互斥问题。如果处理不当,就会出现不可预知的结果。
线程切换和管理会造成计算机效率的降低;线程所需的数据结构也是内存开销。
三、socket编程中的要点
1、socket基本结构
Winsock是windows系统上的一个网络通信API编程接口。TCP/IP协议栈只是winsock通信的一个子集,winsock还可以支持除了tcp/ip之外的其它协议栈。BSD socket是unix上tcp/ip协议栈的编程接口,所以winsock和BSD套接字包含的协议栈不一样。所以winsock编程中对于需要榜定的地址必须说明协议族和地址类型等。因为它可以支持很多通信协议。
winsock说明
图中紫色的长方形代表数据缓冲区,网卡和协议栈都有缓冲区。数据到达以后,首先在网卡的缓冲区。这个时候,通过网卡驱动数据被拷贝到数据所属的协议栈的缓冲区。最后,应用程序可以从协议栈的缓冲区把数据取走。当应用程序发送数据,数据就会首先被缓存到协议栈的缓冲区,协议栈在适当的时候就会通过网卡驱动把数据拷贝到网卡的缓冲区,最后数据就被网卡驱动发送到物理网络上。但是需要明确,网卡的数据缓冲区比协议栈的小的多。所以,协议栈的缓冲区内容是不断的积累网卡缓冲区内容的结果。
2、采用大缓冲区
Winsock API可以让程序员设置整个协议栈缓冲区的大小。把这个缓冲区设置的大一点可以接受更多的客户同时发送数据,也可以支持暂时缓存应用程序发送的数据。
也就是采用大缓冲区的时候,远端的发送程序不会因为协议栈缓存满而发送失败;本地的应用程序也不会因为缓存满而发送失败。或者在流式套接字的时候是发送被阻塞。
3、采用重叠I/O
采用重叠I/O可以提高应用程序收发数据的效率。
如图所示,采用重叠I/O以后数据就会直接从网卡的数据缓冲区拷贝到应用程序的数据缓冲区,从而减少了协议栈的一个数据缓冲环节,消除了很多内存拷贝操作。从而提高了应用程序的效率。
overlapped IO
四、无连接的winsock
1、概述
网络中可以用一个三元组全局唯一地标志一个进程,这个三元组的结构是:(协议、本地地址、本地端口号)。这个三元组叫做一个半相关。
一个完整的网间进程通信需要由两个进程组成,并且只能使用同一种高层协议(如tcp,udp)。就是说不可能一端用tcp,另一端用udp。因此,一个完整的网间通信需要一个五元组来标识:(协议、本地地址、本地端口好、远地地址、远地端口号)。这样一个五元组叫做全相关。也就是同一个协议的两个半相关才能组成一个全相关,也就是一个连接。
无连接的socket
Bind()与是否面向连接有关。产生一个socket以后,Bind()把套接字与本地的一个端口相关联。也就是进程在系统中为自己的通信登记一个地址。这个就类似于为一个服务指定一个电话号码,例如114查询服务或者一个客服热线。而创建一个socket的举措类似于建立一个服务,但是没有指定一个电话号码之前(Bind之前),客户无法与之通信。Bind以后,服务方必须让客户知道这项服务的号码,也就是一个半相关(协议、本地地址、本地端口号)。
Bind()是显式绑定,客户端一般不一定要显式绑定,例如通过connect()、Sendto()等几个方法可以附带绑定,产生客户端半相关。如果客户端显式绑扎,那么客户端其实和一个服务端在概念上没有区别了。在多点对等通信模型中这一点很重要。
首先发送方知道对方的地址,通过sendto()发送数据;接收方通过recvfrom()接收数据,通过这个函数的出口参数可以获知发送方的地址,然后就可以回送数据。所以客户端没有必要显式bind()一个地址。服务器方之所以需要bind()一下,是因为它首先调用recvfrom(),这个接口的参数要求一个已经帮扎了本地地址的socket。如果客户端也显式绑扎一个地址,它就具备了端到端的网络通信的能力了。
无连接的socket二
如图所示的无连接socket编程模型,其客户端并没有使用Bind()显式提供半相关。它是通过Sendto和recvfrom进行附带绑扎。那么服务端如何回送数据呢?recvfrom()提供了一个出口参数,用来返回源地址,通过这个源地址,服务端可以回送数据到客户端。
2、socket详解
socket是一种特殊的I/O,所以socket类似于文件指针、文件句柄。通过socket可以写入和读取数据。
socket原理图
socket这种I/O的特殊性在于创建了一个socket以后并不能马上进行数据读取或者写入操作。它必须和一定的地址联系起来才可以操作。从无连接的协议看看这个过程。
sendto(),首先执行sendto()的一方必须要知道对方的地址,才能sendto(),也就是把数据写入一个socket。
无连接的socket
首先是数据的接受方创建一个socket,然后把一个地址SockAddr1与这个特殊的I/O绑定。然后就可以从这个socket进行数据读取:recvfrom()。这个一般是服务器方。
然后是客户端方面,它显然必须知道服务器端的SockAddr1这个地址才能写入数据。所以,它创建一个特殊的I/O --- socket,然后就进行数据写入:sendto()。这个时候sendto除了向指定的地址写入数据,还要隐式的给这个socket绑定一个本地地址:SockAddr2。
服务方通过recvfrom()除了读取到客户提供的数据以外,还可以通过出口参数获得发送者的地址。它使用sendto(),通过任何一个socket(用socket2或者新建一个,新建是浪费资源,就用socket2就可以了)向客户端回写一些数据。
上述情况时客户端给新建的socket通过sendto()隐式绑定地址,这个地址是系统随机生成的。客户端也可以在sendto()之前采用bind()显式绑定一个地址,然后sendto()就会采用这个显式绑定的地址作为源地址,但是不鼓励这么做。
总结:socket是一种特殊的I/O,通过这种I/O可以读写数据。Socket在使用的时候需要绑定一个地址,这个被绑定的地址就是应用程序用来读取的地址,也就是接收数据的地址。而应用程序写入数据的地址,也就是发送数据的地址并不是和socket需要绑定的,这个地址可以随意变换。所以,在无连接情况下,通过同一个socket可以向多个地址写入数据,但是读取数据的地址只有一个,那就是给这个socket绑定的地址。
无连接多点通信
上图所示的例子我们可以认为上方为服务器。服务器创建soctet1,然后与地址SockAddr2显式绑扎。所以,SockAddr2就是服务器从socket1读取数据的地址,只有一个读取地址。
然后,3个客户端知道服务器地址SockAddr2,它们向服务器写入数据,sendto()。然后,服务器通过recvfrom()获取数据,并且获知三个客户端的绑扎地址(隐式或者显式都可以)。服务器通过三个客户端的地址,通过同一个socket--- socket1,向三个客户端分别写入数据。客户端通过recvfrom()得到服务器数据。
对于一个socket,只能进行一次地址绑扎,即一次bind()。假如把上图认为是一个客户端向三个服务器首先进行sendto()写入数据,客户端并没有显式绑扎SockAddr2地址,那么客户端第一次调用sendto()的时候就会进行隐式绑扎,以后就不会。第一次绑扎分配的SockAddr2不会再改变,三个服务器通过recvfrom()获取到的源地址都是SockAddr2。
广播:从以上原理,可以知道无连接协议的广播是socket的发送地址的一个特例,而与绑定地址和函数没有关系。只要设置sendto()的目的地址SockAddr为广播类型就可以了。
3、无连接socket与多线程
无连接socket很灵活,可以通过同一个socket向很多个地址进行数据写入,从同一个地址进行数据读取。所以这种服务器的组织形式也会很灵活。比如,利用多线程共享同一个服务器端的socket,进行数据读取和写入。
但是需要注意,socket是特殊的I/O,既然属于I/O,那么线程同步与互斥是非常重要的。因为它们读写socket的顺序将不能被保证,或者无法预料。理论上一个端口号对应于不同的缓冲区,也就是端口号是tcp/ip协议栈上数据缓冲区的句柄。
五、有连接的socket
1、概述
有连接的socket,其编程方法与无连接的客户端和服务器端有很大差别。
面向连接的socket
需要说明的是,面向连接的socket变成模型中,服务器端创建一个socket,并把一个地址与这个socket显式绑扎。
面向连接的socket二
为了详细的了解面向连接的socket,我们从accept()开始。
accept()从指定的socket的连接请求等待队列里面取出第一个连接请求,然后它就返回新建的一个socket句柄。这个新建的socket可以完成本次取出的连接请求,并开始为它服务。这个被新建的socket具备和用于监听连接请求的socket一样的属性,包括与之一样的异步选择事件(用 WSAAsyncSelect 或者WSAEventSelect 函数选择的事件)。然后,由新建的socket为accept()本次取出的连接请求服务,而原来的监听请求的socket又可以回到监听状态。
那么面向连接的socket的通信细节和无连接的有何不一样呢?这个需要研究面向连接的socket使用的数据读取和写入接口和其它接口。
accept(),接受客户端的连接请求,并生成一个 socket为这个客户服务。accept()的出口参数可以提供客户方的SockAddr,即地址。但是服务方返回一些数据没必要用这个地址,在面向连接的数据写入方法中,只需要一个socket就可以了, send()。而面向连接的数据读取方法recv()也只需要同一个socket就可以完成。所以这个通信过程细节如下:
服务端创建一个特殊的I/O --- socket,这个socket用来监听客户连接请求。所以,这个socket需要和服务端的本地地址帮扎。从前面知道,bind()地址就是从这个socket上读取数据的地址,不管是显式还是隐式。
然后服务器调用listen(socket, num),num是一个表示连接请求队列的最大值的整数。对于这个socket上并发的连接请求(请求连接绑扎地址),服务器不能马上响应的,就会被缓存字这个队列,等待服务器处理。但是队列满了以后,到来的请求就会不能被响应。listen()是非常关键的一步,只有调用了这一步,服务器才能监听客户端请求。
listen()以后服务器就调用accept(),提供一个出口参数可以获取请求方的地址。当指定的被accept的socket上的连接请求队列空,accept()会被阻塞。但是accept之前,服务器一直在listen请求。
如果这个socket上的连接请求缓存队列有连接请求,那么accept()就会脱离阻塞状态执行。accept()新建一个socket为从队列中取出的当前请求服务,而被accept的socket,或者也就是被listen()的那个socket()继续返回到listen()状态。
面向连接的通信过程
如上图,服务器端创建socket1套接字,然后必须把该套接字和一个本地地址sockaddr1进行显式绑扎,这样就可以从socket1上读取数据的,或者说别人可以发送数据到这个地址。
随后,服务器在套接字socket1上调用listen(),进行请求的监听。listen()指定了请求缓冲队列的大小。listen之前的客户端连接请求connect()会失败。
调用listen()之后,服务器调用accept()从连接请求队列中取出一个连接请求,进行服务。如果队列空,accept被阻塞。accept()从队列中取出一个请求,并创建一个为这个取出的请求服务的套接字newSocket,并从出口参数返回该请求的客户端地址ClientAddr1。newSocket具有两个特点,第一个是具备与socket1一样的属性,这就是说newSocket也是绑扎在地址sockaddr1,这也就是从它读取数据的地址。另外一个是newSocket与ClientAddr1也具备了联系,这个地址是把数据写入newSocket的地方。所以,不需要取出出口参数的ClientAddr1这个客户端地址,仅仅通过新的套接字newSocket服务器端就和某个特定的客户端建立了全相关,就可以读取或者写入数据了。
再看客户端。客户端必须首先得到服务器的绑扎地址sockaddr1,客户端创建一个套接字socket2以后,就在该套接字上调用connect函数。connet()把一个本地地址ClientAddr1隐式绑扎到套接字socket2,作为数据接收地址。并且,connect把服务器地址sockaddr1和socket2联系起来。所以,通过socket2,客户端就可以进行读出和写入数据。
面向连接三
如上图所示,全相关的建立过程。现在我们有个统一的观点,在一个socket上进行bind()一个本地地址(只能是本地地址才能被绑扎),就是本地程序在这个socket上的数据读取地址;但对通信的另一方来说就是数据的写入地址。服务器端为每一个不同的客户端产生一个newSocket进行服务,它们不同的地方就是这些newSocket具有不同的数据写入地址。但是具有一致的绑扎地址,尽管如此,不同的客户端发送的数据不会混淆,看来读取地址与socket句柄有关,所以不同的newSocket虽然具备同一个读取地址,但是会读到各自的数据。
客户端提前知道服务端地址,这是客户端的写地址。通过connect()请求,connect()隐式给客户端绑扎一个本地地址作为读取地址,并且显示绑扎服务端地址作为发送地址。服务器接受请求,并取得客户端地址,作为写地址。这样一来,双方的socket都具备了读写能力,所以建立了一个数据连接通道。
2、socket地址
根据前面的分析,我们可以认为soket句柄和本地的绑定地址共同确定了协议栈上的数据接收缓冲区或者read缓冲区。而协议栈上的写缓冲区或者发送是被公用的(但是不同的协议无法公用,例如tcp和udp)。所以,对同一个地址,不同的socket可以收到不同的内容。但是对一个socket上的地址绑扎,无论是显式还是隐式,只能进行一次。
3、并发连接
如果客户端掉用connect进行连接请求,多个客户端可能存在并发请求。服务器会把不能响应的请求缓存在listen()指定了大小的请求队列。这个时候被缓存了请求的客户端connect()方法会正确返回,并继续执行。但是会在 send和recv方法上被阻塞等待。
如果并发数目大于连接请求的缓冲区大小,那么不能被缓存的那些连接connect()方法会返回(暂不清楚有没有返回错误)。但是在这个socket上调用send或者recv方法,就会返回错误结果。
所以,如果连接请求的服务过程比较费时间,那么为了不至于被缓存请求的客户端长时间等待和另一些客户端连接失败,一般需要采用多线程方式。因为把服务交给子线程以后,主线程总有机会accept更多的请求。所以,除了把请求队列设置大一些,多线程也是改善服务的方法。
4、connect()
connect()有一个作用,从面向连接的例子看就是把一个数据发送或者写入地址帮顶到套接字上,从而使得该套接字绑定了两个地址。显式绑扎发送地址和隐式绑扎接收地址。
无连接的协议也可以调用connect(),但是这种情况下connect()并不会向服务器进行连接请求。这个时候就是把一个地址显示绑扎到某个套接字,使它具备一个关联的数据发送地址。这样,无连接协议也可以使用send()和recv()在这样一个套接字上写如和读取数据。虽然已经绑扎了一个默认的发送地址,但是通过sendto()又可以把数据发送到非默认的地址。
bind()用来显式给一个socket绑扎一个数据读取地址,这是本地地址。客户端不鼓励这种方式,而是采用隐式绑扎。但是如果显式绑扎了也不会错。
[转自http://www.cnblogs.com/worldreason/category/139359.html]