.NET Core微服务之路:利用DotNetty实现一个简单的通信过程
- 服务端启动并且向注册中心发送服务信息,注册中心收到后会定时监控服务状态(常见心跳检测);
- 客户端需要开始调用服务的时候,首先去注册中心获取服务信息;
- 客户端创建远程调用连接,连接后服务端返回处理信息;
- 服务发现,向注册中心获取服务(这里需要做的有很多:拿到多个服务时需要做负载均衡,同机房过滤、版本过滤、服务路由过滤、统一网关等);
- 客户端发起调用,将需要调用的服务、方法、参数进行组装;
- 序列化编码组装的消息,这里可以使用json,也可以使用xml,也可以使用protobuf,也可以使用hessian,几种方案的序列化速度还有序列化后占用字节大小都是选择的重要指标,对内笔者建议使用高效的protobuf,它基于TCP/IP二进制进行序列化,体积小,速度快。
- 传输协议,可以使用传统的io阻塞传输,也可以使用高效的nio传输(Netty);
- 服务端收到后进行反序列化,然后进行相应的处理;
- 服务端序列化response信息并且返回;
- 客户端收到response信息并且反序列化;
- 序列化采用二进制消息,性能好/效率高(空间和时间效率都很不错);
- 序列化反序列化直接对应程序中的数据类,不需要解析后在进行映射(XML,JSON都是这种方式);
- 相比http协议,没有无用的header,简化传输数据的大小,且基于TCP层传输,速度更快,容量更小;
- Netty等一些框架集成(重点,也是本篇介绍的主要框架);
- 使用复杂,维护成本和学习成本较高,调试困难;
- 因为基于HTTP2,绝大部多数HTTP Server、Nginx都尚不支持,即Nginx不能将GRPC请求作为HTTP请求来负载均衡,而是作为普通的TCP请求。(nginx1.9版本已支持);
- 二进制可读性差,或者几乎没有任何直接可读性,需要专门的工具进行反序列化;
- 默认不具备动态特性(可以通过动态定义生成消息类型或者动态编译支持,后续会介绍利用Rosyln进行动态编译的特性);
通信传输利器Netty(Net is DotNetty)介绍
传统通讯的问题:
解决:
(DotNetty的框架和实现是怎么回事,笔者不太清楚,但完全可参考Netty官方的文档来学习和使用DotNetty相关的API接口)
DotNetty中几个重要的库(程序集):
直接上点对点之间通讯的栗子
/*
* Netty 是一个半成品,作用是在需要基于自定义协议的基础上完成自己的通信封装
* Netty 大大简化了网络程序的开发过程比如 TCP 和 UDP 的 socket 服务的开发。
* “快速和简单”并不意味着应用程序会有难维护和性能低的问题,
* Netty 是一个精心设计的框架,它从许多协议的实现中吸收了很多的经验比如 FTP、SMTP、HTTP、许多二进制和基于文本的传统协议。
* 因此,Netty 已经成功地找到一个方式,在不失灵活性的前提下来实现开发的简易性,高性能,稳定性。
*/ namespace Echo.Server
{
using System;
using System.Threading.Tasks;
using DotNetty.Codecs;
using DotNetty.Handlers.Logging;
using DotNetty.Transport.Bootstrapping;
using DotNetty.Transport.Channels;
using DotNetty.Transport.Libuv;
using Examples.Common; static class Program
{
static async Task RunServerAsync()
{
ExampleHelper.SetConsoleLogger(); // 申明一个主回路调度组
var dispatcher = new DispatcherEventLoopGroup(); /*
Netty 提供了许多不同的 EventLoopGroup 的实现用来处理不同的传输。
在这个例子中我们实现了一个服务端的应用,因此会有2个 NioEventLoopGroup 会被使用。
第一个经常被叫做‘boss’,用来接收进来的连接。第二个经常被叫做‘worker’,用来处理已经被接收的连接,一旦‘boss’接收到连接,就会把连接信息注册到‘worker’上。
如何知道多少个线程已经被使用,如何映射到已经创建的 Channel上都需要依赖于 IEventLoopGroup 的实现,并且可以通过构造函数来配置他们的关系。
*/ // 主工作线程组,设置为1个线程
IEventLoopGroup bossGroup = dispatcher; // (1)
// 子工作线程组,设置为1个线程
IEventLoopGroup workerGroup = new WorkerEventLoopGroup(dispatcher); try
{
// 声明一个服务端Bootstrap,每个Netty服务端程序,都由ServerBootstrap控制,通过链式的方式组装需要的参数
var serverBootstrap = new ServerBootstrap(); // (2)
// 设置主和工作线程组
serverBootstrap.Group(bossGroup, workerGroup); // 申明服务端通信通道为TcpServerChannel
serverBootstrap.Channel<TcpServerChannel>(); // (3) serverBootstrap
// 设置网络IO参数等
.Option(ChannelOption.SoBacklog, ) // (5) // 在主线程组上设置一个打印日志的处理器
.Handler(new LoggingHandler("SRV-LSTN")) // 设置工作线程参数
.ChildHandler(
/*
* ChannelInitializer 是一个特殊的处理类,他的目的是帮助使用者配置一个新的 Channel。
* 也许你想通过增加一些处理类比如DiscardServerHandler 来配置一个新的 Channel 或者其对应的ChannelPipeline 来实现你的网络程序。
* 当你的程序变的复杂时,可能你会增加更多的处理类到 pipline 上,然后提取这些匿名类到最顶层的类上。
*/
new ActionChannelInitializer<IChannel>( // (4)
channel =>
{
/*
* 工作线程连接器是设置了一个管道,服务端主线程所有接收到的信息都会通过这个管道一层层往下传输,
* 同时所有出栈的消息 也要这个管道的所有处理器进行一步步处理。
*/
IChannelPipeline pipeline = channel.Pipeline; // 添加日志拦截器
pipeline.AddLast(new LoggingHandler("SRV-CONN")); // 添加出栈消息,通过这个handler在消息顶部加上消息的长度。
// LengthFieldPrepender(2):使用2个字节来存储数据的长度。
pipeline.AddLast("framing-enc", new LengthFieldPrepender()); /*
入栈消息通过该Handler,解析消息的包长信息,并将正确的消息体发送给下一个处理Handler
1,InitialBytesToStrip = 0, //读取时需要跳过的字节数
2,LengthAdjustment = -5, //包实际长度的纠正,如果包长包括包头和包体,则要减去Length之前的部分
3,LengthFieldLength = 4, //长度字段的字节数 整型为4个字节
4,LengthFieldOffset = 1, //长度属性的起始(偏移)位
5,MaxFrameLength = int.MaxValue, //最大包长
*/
pipeline.AddLast("framing-dec", new LengthFieldBasedFrameDecoder(ushort.MaxValue, , , , )); // 业务handler
pipeline.AddLast("echo", new EchoServerHandler());
})); // bootstrap绑定到指定端口的行为就是服务端启动服务,同样的Serverbootstrap可以bind到多个端口
IChannel boundChannel = await serverBootstrap.BindAsync(ServerSettings.Port); // (6) Console.WriteLine("wait the client input");
Console.ReadLine(); // 关闭服务
await boundChannel.CloseAsync();
}
finally
{
// 释放指定工作组线程
await Task.WhenAll( // (7)
bossGroup.ShutdownGracefullyAsync(TimeSpan.FromMilliseconds(), TimeSpan.FromSeconds()),
workerGroup.ShutdownGracefullyAsync(TimeSpan.FromMilliseconds(), TimeSpan.FromSeconds())
);
}
} static void Main() => RunServerAsync().Wait();
}
}
- IEventLoopGroup 是用来处理I/O操作的多线程事件循环器,DotNetty 提供了许多不同的 EventLoopGroup 的实现用来处理不同的传输。在这个例子中我们实现了一个服务端的应用,因此会有2个 IEventLoopGroup 会被使用。第一个经常被叫做‘boss’,用来接收进来的连接。第二个经常被叫做‘worker’,用来处理已经被接收的连接,一旦‘boss’接收到连接,就会把连接信息注册到‘worker’上。
- ServerBootstrap 是一个启动 Transport 服务的辅助启动类。你可以在这个服务中直接使用 Channel,但是这会是一个复杂的处理过程,在很多情况下你并不需要这样做。
- 这里我们指定使用 TcpServerChannel类来举例说明一个新的 Channel 如何接收进来的连接。
- ChannelInitializer 是一个特殊的处理类,他的目的是帮助使用者配置一个新的 Channel,当你的程序变的复杂时,可能你会增加更多的处理类到 pipline 上,然后提取这些匿名类到最顶层的类上。
- 你可以设置这里指定的 Channel 实现的配置参数。我们正在写一个TCP/IP 的服务端,因此我们被允许设置 socket 的参数选项比如tcpNoDelay 和 keepAlive。
- 绑定端口然后启动服务,这里我们在机器上绑定了机器网卡上的设置端口,当然现在你可以多次调用 bind() 方法(基于不同绑定地址)。
- 使用完成后,优雅的释放掉指定的工作组线程,当然,你可以选择关闭程序,但这并不推荐。
Server端的事件处理代码:
上一部分代码中加粗地方的实现
namespace Echo.Server
{
using System;
using System.Text;
using DotNetty.Buffers;
using DotNetty.Transport.Channels; /// <summary>
/// 服务端处理事件函数
/// </summary>
public class EchoServerHandler : ChannelHandlerAdapter // ChannelHandlerAdapter 业务继承基类适配器 // (1)
{
/// <summary>
/// 管道开始读
/// </summary>
/// <param name="context"></param>
/// <param name="message"></param>
public override void ChannelRead(IChannelHandlerContext context, object message) // (2)
{
if (message is IByteBuffer buffer) // (3)
{
Console.WriteLine("Received from client: " + buffer.ToString(Encoding.UTF8));
} context.WriteAsync(message); // (4)
} /// <summary>
/// 管道读取完成
/// </summary>
/// <param name="context"></param>
public override void ChannelReadComplete(IChannelHandlerContext context) => context.Flush(); // (5) /// <summary>
/// 出现异常
/// </summary>
/// <param name="context"></param>
/// <param name="exception"></param>
public override void ExceptionCaught(IChannelHandlerContext context, Exception exception)
{
Console.WriteLine("Exception: " + exception);
context.CloseAsync();
}
}
}
- DiscardServerHandler 继承自 ChannelInboundHandlerAdapter,这个类实现了IChannelHandler接口,IChannelHandler提供了许多事件处理的接口方法,然后你可以覆盖这些方法。现在仅仅只需要继承 ChannelInboundHandlerAdapter 类而不是你自己去实现接口方法。
- 这里我们覆盖了 chanelRead() 事件处理方法。每当从客户端收到新的数据时,这个方法会在收到消息时被调用,这个例子中,收到的消息的类型是 ByteBuf。
- 为了响应或显示客户端发来的信息,为此,我们将在控制台中打印出客户端传来的数据。
- 然后,我们将客户端传来的消息通过context.WriteAsync写回到客户端。
- 当然,步骤4只是将流缓存到上下文中,并没执行真正的写入操作,通过执行Flush将流数据写入管道,并通过context传回给传来的客户端。
Client端代码:
重点看注释的地方,其他地方跟Server端没有任何区别
namespace Echo.Client
{
using System;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using DotNetty.Buffers;
using DotNetty.Codecs;
using DotNetty.Handlers.Logging;
using DotNetty.Transport.Bootstrapping;
using DotNetty.Transport.Channels;
using DotNetty.Transport.Channels.Sockets;
using Examples.Common; static class Program
{
static async Task RunClientAsync()
{
ExampleHelper.SetConsoleLogger(); var group = new MultithreadEventLoopGroup(); try
{
var bootstrap = new Bootstrap();
bootstrap
.Group(group)
.Channel<TcpSocketChannel>()
.Option(ChannelOption.TcpNodelay, true)
.Handler(
new ActionChannelInitializer<ISocketChannel>(
channel =>
{
IChannelPipeline pipeline = channel.Pipeline;
pipeline.AddLast(new LoggingHandler());
pipeline.AddLast("framing-enc", new LengthFieldPrepender());
pipeline.AddLast("framing-dec", new LengthFieldBasedFrameDecoder(ushort.MaxValue, , , , )); pipeline.AddLast("echo", new EchoClientHandler());
})); IChannel clientChannel = await bootstrap.ConnectAsync(new IPEndPoint(ClientSettings.Host, ClientSettings.Port)); // 建立死循环,类同于While(true)
for (;;) // (4)
{
Console.WriteLine("input you data:");
// 根据设置建立缓存区大小
IByteBuffer initialMessage = Unpooled.Buffer(ClientSettings.Size); // (1)
string r = Console.ReadLine();
// 将数据流写入缓冲区
initialMessage.WriteBytes(Encoding.UTF8.GetBytes(r ?? throw new InvalidOperationException())); // (2)
// 将缓冲区数据流写入到管道中
await clientChannel.WriteAndFlushAsync(initialMessage); // (3)
if(r.Contains("bye"))
break;
} Console.WriteLine("byebye"); await clientChannel.CloseAsync();
}
finally
{
await group.ShutdownGracefullyAsync(TimeSpan.FromMilliseconds(), TimeSpan.FromSeconds());
}
} static void Main() => RunClientAsync().Wait();
}
}
- 初始化一个缓冲区的大小。
- 默认缓冲区接受的数据类型为bytes[],当然这样也更加便于序列化成流。
- 将缓冲区的流直接数据写入到Channel管道中。该管道一般为链接通讯的另一端(C端)。
- 建立死循环,这样做的目的是为了测试每次都必须从客户端输入的数据,通过服务端回路一次后,再进行下一次的输入操作。
Client端的事件处理代码:
namespace Echo.Client
{
using System;
using System.Text;
using DotNetty.Buffers;
using DotNetty.Transport.Channels; public class EchoClientHandler : ChannelHandlerAdapter
{
readonly IByteBuffer initialMessage; public override void ChannelActive(IChannelHandlerContext context) => context.WriteAndFlushAsync(this.initialMessage); public override void ChannelRead(IChannelHandlerContext context, object message)
{
if (message is IByteBuffer byteBuffer)
{
Console.WriteLine("Received from server: " + byteBuffer.ToString(Encoding.UTF8));
}
} public override void ChannelReadComplete(IChannelHandlerContext context) => context.Flush(); public override void ExceptionCaught(IChannelHandlerContext context, Exception exception)
{
Console.WriteLine("Exception: " + exception);
context.CloseAsync();
}
}
}
实现结果
DotNetty内部调试记录分析
虽然DotNetty官方没有提供任何技术文档,但官方却提供了详细的调试记录,很多时候,我们学习者其实也可以通过调试记录来分析某一个功能的实现流程。我们可以通过将DotNetty的内部输入输出记录打印到控制台上。
InternalLoggerFactory.DefaultFactory.AddProvider(new ConsoleLoggerProvider((s, level) => true, false));
可以看到服务端的打印记录一下多出来了许多许多,有大部分是属于DotNetty内部调试时的打印记录,我们只着重看如下的部分。
dbug: SRV-LSTN[]
[id: 0x3e8afca1] HANDLER_ADDED
dbug: SRV-LSTN[]
[id: 0x3e8afca1] REGISTERED ()
dbug: SRV-LSTN[]
[id: 0x3e8afca1] BIND: 0.0.0.0: ()
wait the client input
dbug: SRV-LSTN[]
[id: 0x3e8afca1, 0.0.0.0:] ACTIVE ()
dbug: SRV-LSTN[]
[id: 0x3e8afca1, 0.0.0.0:] READ ()
dbug: SRV-LSTN[]
[id: 0x3e8afca1, 0.0.0.0:] RECEIVED: [id: 0x7bac2775, 127.0.0.1: :> 127.0.0.1:] ()
dbug: SRV-LSTN[]
[id: 0x3e8afca1, 0.0.0.0:] RECEIVED_COMPLETE ()
dbug: SRV-LSTN[]
[id: 0x3e8afca1, 0.0.0.0:] READ ()
dbug: SRV-CONN[]
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] HANDLER_ADDED ()
dbug: SRV-CONN[]
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] REGISTERED ()
dbug: SRV-CONN[]
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] ACTIVE ()
dbug: SRV-CONN[]
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] READ ()
dbug: DotNetty.Buffers.AbstractByteBuffer[] ()
-Dio.netty.buffer.bytebuf.checkAccessible: True
dbug: SRV-CONN[]
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] RECEIVED: 14B ()
+-------------------------------------------------+
| a b c d e f |
+--------+-------------------------------------------------+----------------+
|| 0C 6C 6C 6F 6F 6C |..hello world! |
+--------+-------------------------------------------------+----------------+
Received from client: hello world!
dbug: SRV-CONN[] ()
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] WRITE: 2B
+-------------------------------------------------+
| a b c d e f |
+--------+-------------------------------------------------+----------------+
|| 0C |.. |
+--------+-------------------------------------------------+----------------+
dbug: SRV-CONN[] ()
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] WRITE: 12B
+-------------------------------------------------+
| a b c d e f |
+--------+-------------------------------------------------+----------------+
|| 6C 6C 6F 6F 6C |hello world! |
+--------+-------------------------------------------------+----------------+
dbug: SRV-CONN[] ()
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] RECEIVED_COMPLETE
dbug: SRV-CONN[] ()
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] FLUSH
dbug: SRV-CONN[] ()
[id: 0x7bac2775, 127.0.0.1: => 127.0.0.1:] READ
咋一看,有18个操作,好像有点太多了,其实不然,还有很多很多的内部调试细节并没打印到控制台上。
- 通过手动建立的工作线程组,并将这组线程注册到管道中,这个管道可以是基于SOCKER,可以基于IChannel(1);
- 绑定自定的IP地址和端口号到自定义管道上(2);
- 激活自定义管道(3);
- 开始读取(其实也是开始监听)(4);
- 收到来自id为0x7bac2775的客户端连接请求,建立连接,并继续开始监听(5)(6)(7);
- 从第8步开始,日志已经变成id为0x7bac2775的记录了,当然一样包含注册管道,激活管道,开始监听等等与S端一模一样的操作(8)(9)(10)(11)
- 当笔者输入一条"hello world!"数据后,DotNetty.Buffers.AbstractByteBuffer会进行数据类型检查,以便确认能将数据放入到管道中。(12)
- 将数据发送到S端,数据大小为14B,hello world前有两个点,代表这是数据头,紧接着再发送两个点,但没有任何数据,代表数据已经结束。DotNetty将数据的十六进制存储位用易懂的方式表现了出来,很人性化。(13)(14)
- S端收到数据没有任何加工和处理,马上将数据回传到C端。(15)(16)
- 最后,当这个过程完成后,需要将缓存区的数据强制写入到管道中,所以会执行一次Flush操作,整个传输完成。接下来,不管是C端还是S端,继续将自己的状态改成READ,用于监听管道中的各种情况,比如连接状态,数据传输等等(17)。
总结
.NET Core微服务之路:利用DotNetty实现一个简单的通信过程的更多相关文章
- .NET Core微服务之路:文章系列和内容索引汇总 (v0.52)
微服务架构,对于从事JAVA架构的童鞋来说,早已不是什么新鲜的事儿,他们有鼎鼎大名的Spring Cloud这样的全家桶框架支撑,包含微服务核心组件如 1. Eureka:实现服务注册与发现. 2. ...
- .NET Core微服务之路:不断更新中的目录 (v0.43)
原文:.NET Core微服务之路:不断更新中的目录 (v0.43) 微服务架构,对于从事JAVA架构的童鞋来说,早已不是什么新鲜的事儿,他们有鼎鼎大名的Spring Cloud这样的全家桶框架支撑, ...
- NET Core微服务之路:实战SkyWalking+Exceptionless体验生产环境下的追踪系统
前言 当一个APM或一个日志中心实际部署在生产环境中时,是有点力不从心的. 比如如下场景分析的问题: 从APM上说,知道某个节点出现异常,或延迟过过高,却不能及时知道日志反馈情况,总不可能去相应的节点 ...
- NET Core微服务之路:实战SkyWalking+Exceptionless体验生产下追踪系统
原文:NET Core微服务之路:实战SkyWalking+Exceptionless体验生产下追踪系统 前言 当一个APM或一个日志中心实际部署在生产环境中时,是有点力不从心的. 比如如下场景分析的 ...
- NET Core微服务之路:自己动手实现Rpc服务框架,基于DotEasy.Rpc服务框架的介绍和集成
本篇内容属于非实用性(拿来即用)介绍,如对框架设计没兴趣的朋友,请略过. 快一个月没有写博文了,最近忙着两件事; 一:阅读刘墉先生的<说话的魅力>,以一种微妙的,你我大家都会经常遇见 ...
- NET Core微服务之路:基于Ocelot的API网关Relay实现--RPC篇
前言 我们都知道,API网关是工作在应用层上网关程序,为何要这样设计呢,而不是将网关程序直接工作在传输层.或者网络层等等更底层的环境呢?让我们先来简单的了解一下TCP/IP的五层模型. (图片 ...
- NET Core微服务之路:简单谈谈对ELK,Splunk,Exceptionless统一日志收集中心的心得体会
前言 日志,一直以来都是开发人员和运维人员最关心的问题.开发人员可通过日志记录来协助问题定位,运维人员可通过日志发现系统隐患,故障等定位问题.如果你的系统中没有日志,就像一个断了线的风筝,你永远不知道 ...
- .NET Core微服务之路:让我们对上一个Demo通讯进行修改,完成RPC通讯
最近一段时间有些事情耽搁了更新,抱歉各位了. 上一篇我们简单的介绍了DotNetty通信框架,并简单的介绍了基于DotNetty实现了回路(Echo)通信过程. 我们来回忆一下上一个项目的整个流程: ...
- .NET Core微服务之路:基于gRPC服务发现与服务治理的方案
重温最少化集群搭建,我相信很多朋友都已经搭建出来,基于Watch机制也实现了出来,相信也有很多朋友有了自己的实现思路,但是,很多朋友有个疑问,我API和服务分离好了,怎么通过服务中心进行发现呢,这个过 ...
随机推荐
- 直接从硬盘安装centos7网址整理
1.https://blog.csdn.net/happy_joker/article/details/52822025 注意:(1)第3步-->Linux引导安装-->软件选择--> ...
- JAVA 没有重载运算符,那么 String 类型的加法是怎么实现的,以及String类型不可变的原因和好处
1, JAVA 不具备 C++ 和 C# 一样的重载运算符 来实现类与类之间相互计算 的功能 这其实一定程度上让编程失去了代码的灵活性, 但是个人认为,这在一定程度上减少了代码异常的概率 ...
- tomcat 部署swagger 请求到后端乱码
问题: @ApiOperation(value = "", notes = "查看关键词列表") @ResponseBody @RequestMapping(v ...
- Springboot学习06-Spring AOP封装接口自定义校验
Springboot学习06-Spring AOP封装接口自定义校验 关键字 BindingResult.Spring AOP.自定义注解.自定义异常处理.ConstraintValidator 前言 ...
- 顶级项目孵化的故事系列——Kylin的心路历程【转】
现在已经名满天下的 Apache Kylin,是 Hadoop 大数据生态系统不可或缺的一部分,要知道在 Kylin 项目早期,可是以华人为主的开源团队,一路披荆斩棘经过几年的奋斗,才在 Apache ...
- js string to date
Date.prototype.pattern=function(fmt) { //alert(this.getFullYear()); fmt=fmt.toUpperCase(); var o = { ...
- ES之五:ElasticSearch聚合
前言 说完了ES的索引与检索,接着再介绍一个ES高级功能API – 聚合(Aggregations),聚合功能为ES注入了统计分析的血统,使用户在面对大数据提取统计指标时变得游刃有余.同样的工作,你在 ...
- 利用python如何实现团队成员动态抓阄?
解决思路: 1 确定团队成员个数num,然后根据成员个数生成元素非重复的数组: 2 构成一个团队成员字典,键:成员名 值:0, 然后将生成的数组分别赋值给字典键对应的值: 话不多说,看代码便知: # ...
- linux就该这么学,第六天了
今天学了第六天了,主要讲计划任务了,,at,命令,单次有效,一次性的,crontd服务(周期性)计划任务,crontab -e创建,编辑计划任务.crontab -l查看计划任务,crontaab - ...
- java多线程系列7 高级同步工具(1)信号量Semaphore
Semaphore叫做信号量 可以控制某个资源可被同时访问的个数, acquire() 获取一个许可,得到许可才能执行后面的代码,如果没有就等待. release() 释放一个许可. 当信号量的只允许 ...