Python GUI之tkinter窗口视窗教程大集合(看这篇就够了)
一、前言
由于本篇文章较长,所以下面给出内容目录方便跳转阅读,当然也可以用博客页面最右侧的文章目录导航栏进行跳转查阅。
二、Tkinter是什么
Tkinter 是使用 python 进行窗口视窗设计的模块。Tkinter模块("Tk 接口")是Python的标准Tk GUI工具包的接口。作为 python 特定的GUI界面,是一个图像的窗口,tkinter是python 自带的,可以编辑的GUI界面,我们可以用GUI 实现很多直观的功能,比如想开发一个计算器,如果只是一个程序输入,输出窗口的话,是没用用户体验的。所有开发一个图像化的小窗口,就是必要的。
对于稍有GUI编程经验的人来说,Python的Tkinter界面库是非常简单的。python的GUI库非常多,选择Tkinter,一是最为简单,二是自带库,不需下载安装,随时使用,三则是从需求出发,Python作为一种脚本语言,一种胶水语言,一般不会用它来开发复杂的桌面应用,它并不具备这方面的优势,使用Python,可以把它作为一个灵活的工具,而不是作为主要开发语言,那么在工作中,需要制作一个小工具,肯定是需要有界面的,不仅自己用,也能分享别人使用,在这种需求下,Tkinter是足够胜任的!
这篇文章主要做一个简单概述和实践编程,对于从没有接触过GUI的新手,在脑中树立一个基本的界面编程概念,同时自己也能学会如何简单的实现一些小的图形窗口功能。
对于Tkinter编程,可以用两个比喻来理解:
- 第一个,作画。我们都见过美术生写生的情景,先支一个画架,放上画板,蒙上画布,构思内容,用铅笔画草图,组织结构和比例,调色板调色,最后画笔勾勒。相应的,对应到tkinter编程,那么我们的显示屏就是支起来的画架,根窗体就是画板,在tkinter中则是Toplevel,画布就是tkinter中的容器(Frame),画板上可以放很多张画布(Convas),tkinter中的容器中也可以放很多个容器,绘画中的构图布局则是tkinter中的布局管理器(几何管理器),绘画的内容就是tkinter中的一个个小组件,一幅画由许多元素构成,而我们的GUI界面,就是有一个个组件拼装起来的,它们就是widget。
- 第二个,我们小时候都玩过积木,只要发挥创意,相同的积木可以堆出各种造型。tkinter的组件也可以看做一个个积木,形状或许不同,其本质都是一样的,就是一个积木,不管它长什么样子,它始终就是积木!所以这些小组件都有许多共性,另外,个人认为,学习界面编程,最重要的不是一开始学习每个积木的样子,不是学习每个组件怎么用,而是这些组件该怎么放。初始学习中,怎么放远远比怎么用重要的多。网上有大量的文章资料,基本全是介绍组件怎么用的,对于怎么放,也就是tkinter中的布局管理器,都是一笔带过,这对初学者有点本末倒置,或许绝大部分是转载的原因吧,极少是自己真正写的。组件怎么用不是最迫切的,用到的时候再去了解也不迟,边用边学反而更好。因此我将专门写一章,详细介绍布局管理器的使用。
三、Tkinter 控件详细介绍
1. Tkinter 模块元素简要说明
2. 常用窗口部件及简要说明:
Tkinter支持16个核心的窗口部件,这个16个核心窗口部件类简要描述如下:
Button:一个简单的按钮,用来执行一个命令或别的操作。
Canvas:组织图形。这个部件可以用来绘制图表和图,创建图形编辑器,实现定制窗口部件。
Checkbutton:代表一个变量,它有两个不同的值。点击这个按钮将会在这两个值间切换。
Entry:文本输入域。
Frame:一个容器窗口部件。帧可以有边框和背景,当创建一个应用程序或dialog(对话)版面时,帧被用来组织其它的窗口部件。
Label:显示一个文本或图象。
Listbox:显示供选方案的一个列表。listbox能够被配置来得到radiobutton或checklist的行为。
Menu:菜单条。用来实现下拉和弹出式菜单。
Menubutton:菜单按钮。用来实现下拉式菜单。
Message:显示一文本。类似label窗口部件,但是能够自动地调整文本到给定的宽度或比率。
Radiobutton:代表一个变量,它可以有多个值中的一个。点击它将为这个变量设置值,并且清除与这同一变量相关的其它radiobutton。
Scale:允许你通过滑块来设置一数字值。
Scrollbar:为配合使用canvas, entry, listbox, and text窗口部件的标准滚动条。
Text:格式化文本显示。允许你用不同的样式和属性来显示和编辑文本。同时支持内嵌图象和窗口。
Toplevel:一个容器窗口部件,作为一个单独的、最上面的窗口显示。
messageBox:消息框,用于显示你应用程序的消息框。(Python2中为tkMessagebox)
注意在Tkinter中窗口部件类没有分级;所有的窗口部件类在树中都是兄弟关系。
所有这些窗口部件提供了Misc和几何管理方法、配置管理方法和部件自己定义的另外的方法。此外,Toplevel类也提供窗口管理接口。这意味一个典型的窗口部件类提供了大约150种方法。
四、动手实践学习
1. 创建主窗口及Label部件(标签)创建使用
我们要学习使用上面提到的这些控件首先要创建一个主窗口,就像作画一样,先要架好架子和画板,然后才能在上面放画纸和各种绘画元素,创建好主窗口才能在上面放置各种控件元素。而创建过程是很简单的,如下:
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上设定标签 l = tk.Label(window, text = '你好!this is Tkinter' , bg = 'green' , font = ( 'Arial' , 12 ), width = 30 , height = 2 )
# 说明: bg为背景,font为字体,width为长,height为高,这里的长和高是字符的长和高,比如height=2,就是标签有2个字符这么高 # 第5步,放置标签 l.pack() # Label内容content区域放置位置,自动调节尺寸
# 放置lable的方法有:1)l.pack(); 2)l.place(); # 第6步,主窗口循环显示 window.mainloop() # 注意,loop因为是循环的意思,window.mainloop就会让window不断的刷新,如果没有mainloop,就是一个静态的window,传入进去的值就不会有循环,mainloop就相当于一个很大的while循环,有个while,每点击一次就会更新一次,所以我们必须要有循环 # 所有的窗口文件都必须有类似的mainloop函数,mainloop是窗口文件的关键的关键。 |
测试效果:
2. Button窗口部件
简单说明:
Button(按钮)部件是一个标准的Tkinter窗口部件,用来实现各种按钮。按钮能够包含文本或图象,并且你能够将按钮与一个Python函数或方法相关联。当这个按钮被按下时,Tkinter自动调用相关联的函数或方法。
按钮仅能显示一种字体,但是这个文本可以跨行。另外,这个文本中的一个字母可以有下划线,例如标明一个快捷键。默认情况,Tab键用于将焦点移动到一个按钮部件。
什么时候用按钮部件
简言之,按钮部件用来让用户说“马上给我执行这个任务”,通常我们用显示在按钮上的文本或图象来提示。按钮通常用在工具条中或应用程序窗口中,并且用来接收或忽略输入在对话框中的数据。关于按钮和输入的数据的配合,可以参看Checkbutton和Radiobutton部件。
如何创建:
普通的按钮很容易被创建,仅仅指定按钮的内容(文本、位图、图象)和一个当按钮被按下时的回调函数即可:
b = tk.Button(window, text="hit me", command=hit_me)
没有回调函数的按钮是没有用的,当你按下这个按钮时它什么也不做。你可能在开发一个应用程序的时候想实现这种按钮,比如为了不干扰你的beta版的测试者:
b = tk.Button(window, text="Help", command=DISABLED)
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上设定标签 var = tk.StringVar() # 将label标签的内容设置为字符类型,用var来接收hit_me函数的传出内容用以显示在标签上
l = tk.Label(window, textvariable = var, bg = 'green' , fg = 'white' , font = ( 'Arial' , 12 ), width = 30 , height = 2 )
# 说明: bg为背景,fg为字体颜色,font为字体,width为长,height为高,这里的长和高是字符的长和高,比如height=2,就是标签有2个字符这么高 l.pack() # 定义一个函数功能(内容自己*编写),供点击Button按键时调用,调用命令参数command=函数名 on_hit = False
def hit_me():
global on_hit
if on_hit = = False :
on_hit = True
var. set ( 'you hit me' )
else :
on_hit = False
var. set ('')
# 第5步,在窗口界面设置放置Button按键 b = tk.Button(window, text = 'hit me' , font = ( 'Arial' , 12 ), width = 10 , height = 1 , command = hit_me)
b.pack() # 第6步,主窗口循环显示 window.mainloop() |
测试效果:
3. Entry窗口部件
简单说明:
Entry是tkinter类中提供的的一个单行文本输入域,用来输入显示一行文本,收集键盘输入(类似 HTML 中的 text)。
什么时候用:
需要用户输入用户信息时,比如我们平时使用软件、登录网页时,用户交互界面让我们登录账户信息等时候可以用到。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上设定输入框控件entry并放置控件 e1 = tk.Entry(window, show = '*' , font = ( 'Arial' , 14 )) # 显示成密文形式
e2 = tk.Entry(window, show = None , font = ( 'Arial' , 14 )) # 显示成明文形式
e1.pack() e2.pack() # 第5步,主窗口循环显示 window.mainloop() |
测试效果:
4. Text窗口部件
简单说明:
Text是tkinter类中提供的的一个多行文本区域,显示多行文本,可用来收集(或显示)用户输入的文字(类似 HTML 中的 textarea),格式化文本显示,允许你用不同的样式和属性来显示和编辑文本,同时支持内嵌图象和窗口。
什么时候用:
在需要显示编辑用户、产品多行信息时,比如显示用户详细描述文字,产品简介等等,支持随时编辑。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上设定输入框控件entry框并放置 e = tk.Entry(window, show = None ) #显示成明文形式
e.pack() # 第5步,定义两个触发事件时的函数insert_point和insert_end(注意:因为Python的执行顺序是从上往下,所以函数一定要放在按钮的上面) def insert_point(): # 在鼠标焦点处插入输入内容
var = e.get()
t.insert( 'insert' , var)
def insert_end(): # 在文本框内容最后接着插入输入内容
var = e.get()
t.insert( 'end' , var)
# 第6步,创建并放置两个按钮分别触发两种情况 b1 = tk.Button(window, text = 'insert point' , width = 10 ,
height = 2 , command = insert_point)
b1.pack() b2 = tk.Button(window, text = 'insert end' , width = 10 ,
height = 2 , command = insert_end)
b2.pack() # 第7步,创建并放置一个多行文本框text用以显示,指定height=3为文本框是三个字符高度 t = tk.Text(window, height = 3 )
t.pack() # 第8步,主窗口循环显示 window.mainloop() |
测试效果:
5. Listbox窗口部件
简单说明:
Text是tkinter类中提供的的列表框部件,显示供选方案的一个列表。listbox能够被配置来得到radiobutton或checklist的行为。
什么时候用:
在有一个很多内容选项组成的列表提供用户选择时会用到。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上创建一个标签label用以显示并放置 var1 = tk.StringVar() # 创建变量,用var1用来接收鼠标点击具体选项的内容
l = tk.Label(window, bg = 'green' , fg = 'yellow' ,font = ( 'Arial' , 12 ), width = 10 , textvariable = var1)
l.pack() # 第6步,创建一个方法用于按钮的点击事件 def print_selection():
value = lb.get(lb.curselection()) # 获取当前选中的文本
var1. set (value) # 为label设置值
# 第5步,创建一个按钮并放置,点击按钮调用print_selection函数 b1 = tk.Button(window, text = 'print selection' , width = 15 , height = 2 , command = print_selection)
b1.pack() # 第7步,创建Listbox并为其添加内容 var2 = tk.StringVar()
var2. set (( 1 , 2 , 3 , 4 )) # 为变量var2设置值
# 创建Listbox lb = tk.Listbox(window, listvariable = var2) #将var2的值赋给Listbox
# 创建一个list并将值循环添加到Listbox控件中 list_items = [ 11 , 22 , 33 , 44 ]
for item in list_items:
lb.insert( 'end' , item) # 从最后一个位置开始加入值
lb.insert( 1 , 'first' ) # 在第一个位置加入'first'字符
lb.insert( 2 , 'second' ) # 在第二个位置加入'second'字符
lb.delete( 2 ) # 删除第二个位置的字符
lb.pack() # 第8步,主窗口循环显示 window.mainloop() |
测试效果:
6. Radiobutton窗口部件
简单说明:
Radiobutton:代表一个变量,它可以有多个值中的一个。点击它将为这个变量设置值,并且清除与这同一变量相关的其它radiobutton。
什么时候用:
在有一个很多内容选项组成的选项列表提供用户选择时会用到,用户一次只能选择其中一个,不能多选。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上创建一个标签label用以显示并放置 var = tk.StringVar() # 定义一个var用来将radiobutton的值和Label的值联系在一起.
l = tk.Label(window, bg = 'yellow' , width = 20 , text = 'empty' )
l.pack() # 第6步,定义选项触发函数功能 def print_selection():
l.config(text = 'you have selected ' + var.get())
# 第5步,创建三个radiobutton选项,其中variable=var, value='A'的意思就是,当我们鼠标选中了其中一个选项,把value的值A放到变量var中,然后赋值给variable r1 = tk.Radiobutton(window, text = 'Option A' , variable = var, value = 'A' , command = print_selection)
r1.pack() r2 = tk.Radiobutton(window, text = 'Option B' , variable = var, value = 'B' , command = print_selection)
r2.pack() r3 = tk.Radiobutton(window, text = 'Option C' , variable = var, value = 'C' , command = print_selection)
r3.pack() # 第7步,主窗口循环显示 window.mainloop() |
测试效果:
7. Checkbutton窗口部件
简单说明:
Checkbutton:代表一个变量,它有两个不同的值。点击这个按钮将会在这两个值间切换,选择和取消选择。
什么时候用:
在有一个很多内容选项组成的选项列表提供用户选择时会用到,用户一次可以选择多个。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上创建一个标签label用以显示并放置 l = tk.Label(window, bg = 'yellow' , width = 20 , text = 'empty' )
l.pack() # 第6步,定义触发函数功能 def print_selection():
if (var1.get() = = 1 ) & (var2.get() = = 0 ): # 如果选中第一个选项,未选中第二个选项
l.config(text = 'I love only Python ' )
elif (var1.get() = = 0 ) & (var2.get() = = 1 ): # 如果选中第二个选项,未选中第一个选项
l.config(text = 'I love only C++' )
elif (var1.get() = = 0 ) & (var2.get() = = 0 ): # 如果两个选项都未选中
l.config(text = 'I do not love either' )
else :
l.config(text = 'I love both' ) # 如果两个选项都选中
# 第5步,定义两个Checkbutton选项并放置 var1 = tk.IntVar() # 定义var1和var2整型变量用来存放选择行为返回值
var2 = tk.IntVar()
c1 = tk.Checkbutton(window, text = 'Python' ,variable = var1, onvalue = 1 , offvalue = 0 , command = print_selection) # 传值原理类似于radiobutton部件
c1.pack() c2 = tk.Checkbutton(window, text = 'C++' ,variable = var2, onvalue = 1 , offvalue = 0 , command = print_selection)
c2.pack() # 第7步,主窗口循环显示 window.mainloop() |
测试效果:
8. Scale窗口部件
简单说明:
Scale: 尺度(拉动条),允许你通过滑块来设置一数字值。
什么时候用:
在需要用户给出评价等级,或者给出一个评价分数,或者拉动滑动条提供一个具体的数值等等。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上创建一个标签label用以显示并放置 l = tk.Label(window, bg = 'green' , fg = 'white' , width = 20 , text = 'empty' )
l.pack() # 第6步,定义一个触发函数功能 def print_selection(v):
l.config(text = 'you have selected ' + v)<br>
# 第5步,创建一个尺度滑条,长度200字符,从0开始10结束,以2为刻度,精度为0.01,触发调用print_selection函数 s = tk.Scale(window, label = 'try me' , from_ = 0 , to = 10 , orient = tk.HORIZONTAL, length = 200 , showvalue = 0 ,tickinterval = 2 , resolution = 0.01 , command = print_selection)
s.pack() # 第7步,主窗口循环显示 window.mainloop() |
测试效果:
9. Canvas窗口部件
简单说明:
Canvas:画布,提供绘图功能(直线、椭圆、多边形、矩形) 可以包含图形或位图,用来绘制图表和图,创建图形编辑器,实现定制窗口部件。
什么时候用:
在比如像用户交互界面等,需要提供设计的图标、图形、logo等信息是可以用到画布。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上创建 500 * 200 大小的画布并放置各种元素 canvas = tk.Canvas(window, bg = 'green' , height = 200 , width = 500 )
# 说明图片位置,并导入图片到画布上 image_file = tk.PhotoImage( file = 'pic.gif' ) # 图片位置(相对路径,与.py文件同一文件夹下,也可以用绝对路径,需要给定图片具体绝对路径)
image = canvas.create_image( 250 , 0 , anchor = 'n' ,image = image_file) # 图片锚定点(n图片顶端的中间点位置)放在画布(250,0)坐标处
# 定义多边形参数,然后在画布上画出指定图形 x0, y0, x1, y1 = 100 , 100 , 150 , 150
line = canvas.create_line(x0 - 50 , y0 - 50 , x1 - 50 , y1 - 50 ) # 画直线
oval = canvas.create_oval(x0 + 120 , y0 + 50 , x1 + 120 , y1 + 50 , fill = 'yellow' ) # 画圆 用黄色填充
arc = canvas.create_arc(x0, y0 + 50 , x1, y1 + 50 , start = 0 , extent = 180 ) # 画扇形 从0度打开收到180度结束
rect = canvas.create_rectangle( 330 , 30 , 330 + 20 , 30 + 20 ) # 画矩形正方形
canvas.pack() # 第6步,触发函数,用来一定指定图形 def moveit():
canvas.move(rect, 2 , 2 ) # 移动正方形rect(也可以改成其他图形名字用以移动一起图形、元素),按每次(x=2, y=2)步长进行移动
# 第5步,定义一个按钮用来移动指定图形的在画布上的位置 b = tk.Button(window, text = 'move item' ,command = moveit).pack()
# 第7步,主窗口循环显示 window.mainloop() |
所用图片:
当然你可以随意用你的一张图片导入画布试一试效果,图片可以用画图工具改一下像素大小,以免图片太大,导入画布显示不全,当然你也可以用我提供的素材,下面是链接:https://files.cnblogs.com/files/shwee/pic.gif
图片锚定点位置参数图:
测试效果:
10. Menu窗口部件
简单说明:
Menu:菜单条,用来实现下拉和弹出式菜单,点下菜单后弹出的一个选项列表,用户可以从中选择
什么时候用:
在比如像软件或网页交互界面等,需要提供菜单选项功能提供用户选择菜单选项功能时用到。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上创建一个标签用以显示内容并放置 l = tk.Label(window, text = ' ' , bg = 'green' )
l.pack() # 第10步,定义一个函数功能,用来代表菜单选项的功能,这里为了操作简单,定义的功能比较简单 counter = 0
def do_job():
global counter
l.config(text = 'do ' + str (counter))
counter + = 1
# 第5步,创建一个菜单栏,这里我们可以把他理解成一个容器,在窗口的上方 menubar = tk.Menu(window)
# 第6步,创建一个File菜单项(默认不下拉,下拉内容包括New,Open,Save,Exit功能项) filemenu = tk.Menu(menubar, tearoff = 0 )
# 将上面定义的空菜单命名为File,放在菜单栏中,就是装入那个容器中 menubar.add_cascade(label = 'File' , menu = filemenu)
# 在File中加入New、Open、Save等小菜单,即我们平时看到的下拉菜单,每一个小菜单对应命令操作。 filemenu.add_command(label = 'New' , command = do_job)
filemenu.add_command(label = 'Open' , command = do_job)
filemenu.add_command(label = 'Save' , command = do_job)
filemenu.add_separator() # 添加一条分隔线
filemenu.add_command(label = 'Exit' , command = window.quit) # 用tkinter里面自带的quit()函数
# 第7步,创建一个Edit菜单项(默认不下拉,下拉内容包括Cut,Copy,Paste功能项) editmenu = tk.Menu(menubar, tearoff = 0 )
# 将上面定义的空菜单命名为 Edit,放在菜单栏中,就是装入那个容器中 menubar.add_cascade(label = 'Edit' , menu = editmenu)
# 同样的在 Edit 中加入Cut、Copy、Paste等小命令功能单元,如果点击这些单元, 就会触发do_job的功能 editmenu.add_command(label = 'Cut' , command = do_job)
editmenu.add_command(label = 'Copy' , command = do_job)
editmenu.add_command(label = 'Paste' , command = do_job)
# 第8步,创建第二级菜单,即菜单项里面的菜单 submenu = tk.Menu(filemenu) # 和上面定义菜单一样,不过此处实在File上创建一个空的菜单
filemenu.add_cascade(label = 'Import' , menu = submenu, underline = 0 ) # 给放入的菜单submenu命名为Import
# 第9步,创建第三级菜单命令,即菜单项里面的菜单项里面的菜单命令(有点拗口,笑~~~) submenu.add_command(label = 'Submenu_1' , command = do_job) # 这里和上面创建原理也一样,在Import菜单项中加入一个小菜单命令Submenu_1
# 第11步,创建菜单栏完成后,配置让菜单栏menubar显示出来 window.config(menu = menubar)
# 第12步,主窗口循环显示 window.mainloop() |
测试效果:
11. Frame 窗口部件
简单说明:
Frame:框架,用来承载放置其他GUI元素,就是一个容器,是一个在 Windows 上分离小区域的部件, 它能将 Windows 分成不同的区,然后存放不同的其他部件. 同时一个 Frame 上也能再分成两个 Frame, Frame 可以认为是一种容器.
什么时候用:
在比如像软件或网页交互界面等,有不同的界面逻辑层级和功能区域划分时可以用到,让交互界面逻辑更加清晰。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,在图形界面上创建一个标签用以显示内容并放置 tk.Label(window, text = 'on the window' , bg = 'red' , font = ( 'Arial' , 16 )).pack() # 和前面部件分开创建和放置不同,其实可以创建和放置一步完成
# 第5步,创建一个主frame,长在主window窗口上 frame = tk.Frame(window)
frame.pack() # 第6步,创建第二层框架frame,长在主框架frame上面 frame_l = tk.Frame(frame) # 第二层frame,左frame,长在主frame上
frame_r = tk.Frame(frame) # 第二层frame,右frame,长在主frame上
frame_l.pack(side = 'left' )
frame_r.pack(side = 'right' )
# 第7步,创建三组标签,为第二层frame上面的内容,分为左区域和右区域,用不同颜色标识 tk.Label(frame_l, text = 'on the frame_l1' , bg = 'green' ).pack()
tk.Label(frame_l, text = 'on the frame_l2' , bg = 'green' ).pack()
tk.Label(frame_l, text = 'on the frame_l3' , bg = 'green' ).pack()
tk.Label(frame_r, text = 'on the frame_r1' , bg = 'yellow' ).pack()
tk.Label(frame_r, text = 'on the frame_r2' , bg = 'yellow' ).pack()
tk.Label(frame_r, text = 'on the frame_r3' , bg = 'yellow' ).pack()
# 第8步,主窗口循环显示 window.mainloop() |
测试效果:
12. messageBox窗口部件
简单说明:
messageBox:消息框,用于显示你应用程序的消息框。(Python2中为tkMessagebox),其实这里的messageBox就是我们平时看到的弹窗。 我们首先需要定义一个触发功能,来触发这个弹窗,这里我们就放上以前学过的button按钮,通过触发功能,调用messagebox吧,点击button按钮就会弹出提示对话框。下面给出messagebox提示信息的几种形式:
1
2
3
4
5
6
|
tkinter.messagebox.showinfo(title = 'Hi' , message = '你好!' ) # 提示信息对话窗
tkinter.messagebox.showwarning(title = 'Hi' , message = '有警告!' ) # 提出警告对话窗
tkinter.messagebox.showerror(title = 'Hi' , message = '出错了!' ) # 提出错误对话窗
print (tkinter.messagebox.askquestion(title = 'Hi' , message = '你好!' )) # 询问选择对话窗return 'yes', 'no'
print (tkinter.messagebox.askyesno(title = 'Hi' , message = '你好!' )) # return 'True', 'False'
print (tkinter.messagebox.askokcancel(title = 'Hi' , message = '你好!' )) # return 'True', 'False'
|
什么时候用:
在比如像软件或网页交互界面等,有不同的界面逻辑层级和功能区域划分时可以用到,让交互界面逻辑更加清晰。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
import tkinter.messagebox # 要使用messagebox先要导入模块
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第5步,定义触发函数功能 def hit_me():
tkinter.messagebox.showinfo(title = 'Hi' , message = '你好!' ) # 提示信息对话窗
# tkinter.messagebox.showwarning(title='Hi', message='有警告!') # 提出警告对话窗
# tkinter.messagebox.showerror(title='Hi', message='出错了!') # 提出错误对话窗
# print(tkinter.messagebox.askquestion(title='Hi', message='你好!')) # 询问选择对话窗return 'yes', 'no'
# print(tkinter.messagebox.askyesno(title='Hi', message='你好!')) # return 'True', 'False'
# print(tkinter.messagebox.askokcancel(title='Hi', message='你好!')) # return 'True', 'False'
# 第4步,在图形界面上创建一个标签用以显示内容并放置 tk.Button(window, text = 'hit me' , bg = 'green' , font = ( 'Arial' , 14 ), command = hit_me).pack()
# 第6步,主窗口循环显示 window.mainloop() |
测试效果:
13. 窗口部件三种放置方式pack/grid/place
参考来源:
The Grid Geometry Manager
The Pack Geometry Manager
The Place Geometry Manager
1. Grid:The Grid Geometry Manager
grid 是方格, 所以所有的内容会被放在这些规律的方格中。例如:
1
2
3
|
for i in range ( 3 ):
for j in range ( 3 ):
tk.Label(window, text = 1 ).grid(row = i, column = j, padx = 10 , pady = 10 , ipadx = 10 , ipady = 10 )
|
以上的代码就是创建一个三行三列的表格,其实 grid 就是用表格的形式定位的。这里的参数 row 为行,colum 为列,padx 就是单元格左右间距,pady 就是单元格上下间距,ipadx是单元格内部元素与单元格的左右间距,ipady是单元格内部元素与单元格的上下间距。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,grid 放置方法 for i in range ( 3 ):
for j in range ( 3 ):
tk.Label(window, text = 1 ).grid(row = i, column = j, padx = 10 , pady = 10 , ipadx = 10 , ipady = 10 )
# 第5步,主窗口循环显示 window.mainloop() |
测试效果:
2. Pack:The Pack Geometry Manager
我们常用的pack(), 他会按照上下左右的方式排列.例如:
1
2
3
4
|
tk.Label(window, text = 'P' , fg = 'red' ).pack(side = 'top' ) # 上
tk.Label(window, text = 'P' , fg = 'red' ).pack(side = 'bottom' ) # 下
tk.Label(window, text = 'P' , fg = 'red' ).pack(side = 'left' ) # 左
tk.Label(window, text = 'P' , fg = 'red' ).pack(side = 'right' ) # 右
|
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,pack 放置方法 tk.Label(window, text = 'P' , fg = 'red' ).pack(side = 'top' ) # 上
tk.Label(window, text = 'P' , fg = 'red' ).pack(side = 'bottom' ) # 下
tk.Label(window, text = 'P' , fg = 'red' ).pack(side = 'left' ) # 左
tk.Label(window, text = 'P' , fg = 'red' ).pack(side = 'right' ) # 右
# 第5步,主窗口循环显示 window.mainloop() |
测试效果:
3. Place:The Place Geometry Manager
再接下来我们来看place(), 这个比较容易理解,就是给精确的坐标来定位,如此处给的(50, 100),就是将这个部件放在坐标为(x=50, y=100)的这个位置, 后面的参数 anchor='nw',就是前面所讲的锚定点是西北角。例如:
1
|
tk.Label(window, text = 'Pl' , font = ( 'Arial' , 20 ), ).place(x = 50 , y = 100 , anchor = 'nw' )
|
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'My Window' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '500x300' ) # 这里的乘是小x
# 第4步,place 放置方法(精准的放置到指定坐标点的位置上) tk.Label(window, text = 'Pl' , font = ( 'Arial' , 20 ), ).place(x = 50 , y = 100 , anchor = 'nw' )
# 第5步,主窗口循环显示 window.mainloop() |
测试效果:
14. 综合练习,用户登录窗口例子
编写一个用户登录界面,用户可以登录账户信息,如果账户已经存在,可以直接登录,登录名或者登录密码输入错误会提示,如果账户不存在,提示用户注册,点击注册进去注册页面,输入注册信息,确定后便可以返回登录界面进行登录。
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
|
#!/usr/bin/env python # -*- coding: utf-8 -*- # author:洪卫 import tkinter as tk # 使用Tkinter前需要先导入
import tkinter.messagebox
import pickle
# 第1步,实例化object,建立窗口window window = tk.Tk()
# 第2步,给窗口的可视化起名字 window.title( 'Wellcome to Hongwei Website' )
# 第3步,设定窗口的大小(长 * 宽) window.geometry( '400x300' ) # 这里的乘是小x
# 第4步,加载 wellcome image canvas = tk.Canvas(window, width = 400 , height = 135 , bg = 'green' )
image_file = tk.PhotoImage( file = 'pic.gif' )
image = canvas.create_image( 200 , 0 , anchor = 'n' , image = image_file)
canvas.pack(side = 'top' )
tk.Label(window, text = 'Wellcome' ,font = ( 'Arial' , 16 )).pack()
# 第5步,用户信息 tk.Label(window, text = 'User name:' , font = ( 'Arial' , 14 )).place(x = 10 , y = 170 )
tk.Label(window, text = 'Password:' , font = ( 'Arial' , 14 )).place(x = 10 , y = 210 )
# 第6步,用户登录输入框entry # 用户名 var_usr_name = tk.StringVar()
var_usr_name. set ( 'example@python.com' )
entry_usr_name = tk.Entry(window, textvariable = var_usr_name, font = ( 'Arial' , 14 ))
entry_usr_name.place(x = 120 ,y = 175 )
# 用户密码 var_usr_pwd = tk.StringVar()
entry_usr_pwd = tk.Entry(window, textvariable = var_usr_pwd, font = ( 'Arial' , 14 ), show = '*' )
entry_usr_pwd.place(x = 120 ,y = 215 )
# 第8步,定义用户登录功能 def usr_login():
# 这两行代码就是获取用户输入的usr_name和usr_pwd
usr_name = var_usr_name.get()
usr_pwd = var_usr_pwd.get()
# 这里设置异常捕获,当我们第一次访问用户信息文件时是不存在的,所以这里设置异常捕获。
# 中间的两行就是我们的匹配,即程序将输入的信息和文件中的信息匹配。
try :
with open ( 'usrs_info.pickle' , 'rb' ) as usr_file:
usrs_info = pickle.load(usr_file)
except FileNotFoundError:
# 这里就是我们在没有读取到`usr_file`的时候,程序会创建一个`usr_file`这个文件,并将管理员
# 的用户和密码写入,即用户名为`admin`密码为`admin`。
with open ( 'usrs_info.pickle' , 'wb' ) as usr_file:
usrs_info = { 'admin' : 'admin' }
pickle.dump(usrs_info, usr_file)
usr_file.close() # 必须先关闭,否则pickle.load()会出现EOFError: Ran out of input
# 如果用户名和密码与文件中的匹配成功,则会登录成功,并跳出弹窗how are you? 加上你的用户名。
if usr_name in usrs_info:
if usr_pwd = = usrs_info[usr_name]:
tkinter.messagebox.showinfo(title = 'Welcome' , message = 'How are you? ' + usr_name)
# 如果用户名匹配成功,而密码输入错误,则会弹出'Error, your password is wrong, try again.'
else :
tkinter.messagebox.showerror(message = 'Error, your password is wrong, try again.' )
else : # 如果发现用户名不存在
is_sign_up = tkinter.messagebox.askyesno( 'Welcome! ' , 'You have not sign up yet. Sign up now?' )
# 提示需不需要注册新用户
if is_sign_up:
usr_sign_up()
# 第9步,定义用户注册功能 def usr_sign_up():
def sign_to_Hongwei_Website():
# 以下三行就是获取我们注册时所输入的信息
np = new_pwd.get()
npf = new_pwd_confirm.get()
nn = new_name.get()
# 这里是打开我们记录数据的文件,将注册信息读出
with open ( 'usrs_info.pickle' , 'rb' ) as usr_file:
exist_usr_info = pickle.load(usr_file)
# 这里就是判断,如果两次密码输入不一致,则提示Error, Password and confirm password must be the same!
if np ! = npf:
tkinter.messagebox.showerror( 'Error' , 'Password and confirm password must be the same!' )
# 如果用户名已经在我们的数据文件中,则提示Error, The user has already signed up!
elif nn in exist_usr_info:
tkinter.messagebox.showerror( 'Error' , 'The user has already signed up!' )
# 最后如果输入无以上错误,则将注册输入的信息记录到文件当中,并提示注册成功Welcome!,You have successfully signed up!,然后销毁窗口。
else :
exist_usr_info[nn] = np
with open ( 'usrs_info.pickle' , 'wb' ) as usr_file:
pickle.dump(exist_usr_info, usr_file)
tkinter.messagebox.showinfo( 'Welcome' , 'You have successfully signed up!' )
# 然后销毁窗口。
window_sign_up.destroy()
# 定义长在窗口上的窗口
window_sign_up = tk.Toplevel(window)
window_sign_up.geometry( '300x200' )
window_sign_up.title( 'Sign up window' )
new_name = tk.StringVar() # 将输入的注册名赋值给变量
new_name. set ( 'example@python.com' ) # 将最初显示定为'example@python.com'
tk.Label(window_sign_up, text = 'User name: ' ).place(x = 10 , y = 10 ) # 将`User name:`放置在坐标(10,10)。
entry_new_name = tk.Entry(window_sign_up, textvariable = new_name) # 创建一个注册名的`entry`,变量为`new_name`
entry_new_name.place(x = 130 , y = 10 ) # `entry`放置在坐标(150,10).
new_pwd = tk.StringVar()
tk.Label(window_sign_up, text = 'Password: ' ).place(x = 10 , y = 50 )
entry_usr_pwd = tk.Entry(window_sign_up, textvariable = new_pwd, show = '*' )
entry_usr_pwd.place(x = 130 , y = 50 )
new_pwd_confirm = tk.StringVar()
tk.Label(window_sign_up, text = 'Confirm password: ' ).place(x = 10 , y = 90 )
entry_usr_pwd_confirm = tk.Entry(window_sign_up, textvariable = new_pwd_confirm, show = '*' )
entry_usr_pwd_confirm.place(x = 130 , y = 90 )
# 下面的 sign_to_Hongwei_Website
btn_comfirm_sign_up = tk.Button(window_sign_up, text = 'Sign up' , command = sign_to_Hongwei_Website)
btn_comfirm_sign_up.place(x = 180 , y = 120 )
# 第7步,login and sign up 按钮 btn_login = tk.Button(window, text = 'Login' , command = usr_login)
btn_login.place(x = 120 , y = 240 )
btn_sign_up = tk.Button(window, text = 'Sign up' , command = usr_sign_up)
btn_sign_up.place(x = 200 , y = 240 )
# 第10步,主窗口循环显示 window.mainloop() |
测试效果:
15. 其他部件后续再补充...
注:不同电脑可能配置环境略有不同,如有小错误可以自己调试一下。
这世界上很多事情,看起来就像彩虹一样炫目而神奇,实际上背后蕴含着随处可见的原理。就好像静儿几年前买过一件超贵的防辐射服,当时销售人员把手机严严实实的包在防辐射服里,然后让我打电话测试,果然没有信号。
直到今天,静儿为了写文章,把自己蒙在空调被里,额,竟然连不上网。于是掀起一个被角,“大象”的巡检结果哒哒哒的弹出来了。然后我感慨:自己当年何苦买那么贵一个“围裙”。
日志作为逻辑跟踪、线上问题排查、监控报警的有效基础利器被开发人员所熟知。问题发现、定位到解决,都离不开它,真是如彩虹般绚烂。而从它的演进过程也能看到现代互联网发展的一个缩影。
一 最原始的日志:print、alert
啥?这也算日志。
是的。记得大概十年前,不管是C/S模式(客户端+服务端模式)还是B/S(浏览器+服务端模式)。因为只有前端和后端交互这一层,整个项目都做在一个工程里,大家验证基本上用的前端就是alert,后台就是System.out.print。需求决定设计。链路短,这个基本就够用。
二 JUL
java有自己的日志框架,细心的朋友可能注意到过:java.util.logging。
由于其API并不完善,对开发者不是很友好,对于日志的级别分类也不是很清晰。所以大家用的很少。
三 Log4j
有人注意到JUL的一些缺陷,做出了Log for Java。就是曾经风靡一时的Log4j。
Log4j是Apache的一个开源项目,通过使用Log4j,可控制日志信息输送的目的地是控制台、文件、数据库等。也可以控制每一条日志的输出格式,通过定义每一条日志信息的级别,能够更加细致的控制日志的生成过程。
Log4j有7中不同的log级别,按照等级从低到高依次为trace
Log4j支持两种格式的配置文件:properties和xml。包含三个主要的组件:Logger、appender、Layout。
四 JCL
Jakarta对于java开发人员来说,第一反应不是印度尼西亚的首都,而是Jakarta Commons。而JCL就是Jakarta Commons-Logging的缩写。
JakartaCommons是Jakarta的一个子项目,目的是创建和维护独立与其他框架和产品的程序包。
JCL的初衷是:java自身的一些包都用自己的JUL,而Log4j又那么好用。怎么让他们愉快的相处呢?
它就提供了一套API门面来掩盖真正的Logger实现。这就是设计模式里常用的门面模式,可以进行不同Logger实现的切换。它的缺点是算法复杂,出了异常很难修复。
五 SLF4J
SLF4J=Simple Logging Facade for Java,中文叫:简单日志门面。他的作者就是Log4j的作者。
SLF4J为了解决JCL的缺点而生,简单易用。
六 Logback
Log4j的作者是对技术有着执着的追求的技术学者。他为了实现一个可靠、通用、快速而又灵活的Java日志框架这一目标,又发明了Logback。
Logback有三个模块
1.logback-core:日志处理核心组件
2.logback-classic:完整的实现了SLF4j API,用于切换日志实现。
3.logback-access:与Servlet容器集成提供通过http来访问日志的功能。
因为logback比log4j大约快10倍、消耗更少的内存,迁移成本也很低,自动压缩日志、支持多样化配置、不需要重启就可以恢复I/O异常等优势,又名噪一时。反正静儿是用了很多年。
七 Log4j2
Log4j2与Logback非常类似,但是它有自己的亮点:如插件式结构、配置文件优化、异步日志。
八 日志系统
上面所说的日志都是代码层面。但是实际使用时由于线上都是多个节点并行在跑。为了看日志,不得不登录多个服务器。所以聪明的技术人员就想到了使用RSA授权来集中多个服务的操作于一个终端,这样的工具代表是:polysh。
但是这样,工具使用之前的操作还是挺麻烦的,不如使用界面做一个系统。
Scribe是Facebook开源的日志收集系统。它从各种日志源收集日志,存储在*存储系统,进行集中的分析处理。
业界比较流行的做法是使用Scribe+ES。Scribe收集上来的日志放入搜索引擎,用户使用搜索引擎来灵活的查看自己感兴趣的日志,如同访问一个网站。
.NET MVC采用SignalR更新在线用户数
学到新东西就记录一下。也许正好有人需要~~~~~~
由于需要记录当前在线用户,emmmm又是没做过的。。。
本来想用数据库的形式,但是想想这么简单的功能百度肯定有。遨游一波百度,有所收获。。。。
虽然老是那么几篇文章重复。。。。
大概就是在用户登录时Session记录下数据,前台获取展示。下面这个文章感觉蛮好的。
Session方法参考网址:
https://www.cnblogs.com/taobox/p/4466187.html
看文章想起了我项目中有用到SignaIR,这个可以实现聊天室肯定可以知道在线用户人数。果断继续百度得到如下结果。。。。
参考网址 :
https://www.red-gate.com/simple-talk/dotnet/asp-net/tracking-online-users-with-signalr/
SignaIR中有OnConnected,OnConnected,OnDisconnected三个方法,具体实现如下。
public class ChatHub : Hub
{
//当前用户数量
private static int _userCount = 0;
//存储用户信息
private static List<string> list_UserName = new List<string>();
//链接
public override Task OnConnected()
{
var username = Context.User.Identity.Name; //获取用户的名称
if (!list_UserName.Contains(username))
{
_userCount++;
list_UserName.Add(username);
}
else
{
list_UserName.Add(username);
}
var context = GlobalHost.ConnectionManager.GetHubContext<ChatHub>();
context.Clients.All.online(_userCount);//推送全局,也可以推送给指定用户
return base.OnConnected();
}
//重连
public override Task OnConnected()
{
var username = Context.User.Identity.Name;
if (!list_UserName.Contains(username))
{
_userCount++;
list_UserName.Add(username);
}
else
{
list_UserName.Add(username);
}
var context = GlobalHost.ConnectionManager.GetHubContext<ChatHub>();
context.Clients.All.online(_userCount);
return base.OnReconnected();
}
//断开
public override Task OnDisconnected(bool stopCalled)
{
var username = Context.User.Identity.Name;
var count = list_UserName.Where(x => x == username).Count();
if (count == 1)
{
_userCount--;
list_UserName.Remove(username);
}
else
{
list_UserName.Remove(username);
}
var context = GlobalHost.ConnectionManager.GetHubContext<ChatHub>();
context.Clients.All.online(_userCount);
return base.OnDisconnected(stopCalled);
}
}
JS:
var chat = $.connection.chatHub;
chat.client.online = function (count) {
$("#online").html(count); //标签赋值
}
$.connection.hub.start();
这样就能得到有多少用户链接上来,然后我在_Layout.cshtml里面给控件赋值,就实现了。
上面做法有一点欠缺,应该可以看到我在list_UserName集合里面有重复添加和删除的动作。
因为无法确认页面是最后一次关闭(或者是我没找到)所以才采用这种方法实现。
C#多线程编程系列(五)- 使用任务并行库
目录
- 1.1 简介
- 1.2 创建任务
- 1.3 使用任务执行基本的操作
- 1.4 组合任务
- 1.5 将APM模式转换为任务
- 1.6 将EAP模式转换为任务
- 1.7 实现取消选项
- 1.8 处理任务中的异常
- 1.9 并行运行任务
- 1.10 使用TaskScheduler配置任务执行
- 参考书籍
- 笔者水平有限,如果错误欢迎各位批评指正!
本系列首页链接:[C#多线程编程系列(一)- 简介 ]
1.1 简介
在之前的几个章节中,就线程的使用和多线程相关的内容进行了介绍。因为线程涉及到异步、同步、异常传递等问题,所以在项目中使用多线程的代价是比较高昂的,需要编写大量的代码来达到正确性和健壮性。
为了解决这样一些的问题,在.Net Framework 4.0
中引入了一个关于一步操作的API。它叫做任务并行库(Task Parallel Library)。然后在.Net Framwork 4.5
中对它进行了轻微的改进,本文的案例都是用最新版本的TPL库,而且我们还可以使用C# 5.0的新特性await/async
来简化TAP编程,当然这是之后才介绍的。
TPL内部使用了线程池,但是效率更高。在把线程归还回线程池之前,它会在同一线程中顺序执行多少Task,这样避免了一些小任务上下文切换浪费时间片的问题。
任务是对象,其中封装了以异步方式执行的工作,但是委托也是封装了代码的对象。任务和委托的区别在于,委托是同步的,而任务是异步的。
在本章中,我们将会讨论如何使用TPL库来进行任务之间的组合同步,如何将遗留的APM和EAP模式转换为TPL模式等等。
1.2 创建任务
在本节中,主要是演示了如何创建一个任务。其主要用到了System.Threading.Tasks
命名空间下的Task
类。该类可以被实例化并且提供了一组静态方法,可以方便快捷的创建任务。
在下面实例代码中,分别延时了三种常见的任务创建方式,并且创建任务是可以指定任务创建的选项,从而达到最优的创建方式。
在TaskCreationOptions
中一共有7个枚举,枚举是可以使用|
运算符组合定义的。其枚举如下表所示。
成员名称 | 说明 |
---|---|
AttachedToParent | 指定将任务附加到任务层次结构中的某个父级。 默认情况下,子任务(即由外部任务创建的内部任务)将独立于其父任务执行。 可以使用 TaskContinuationOptions.AttachedToParent 选项以便将父任务和子任务同步。请注意,如果使用 DenyChildAttach 选项配置父任务,则子任务中的 AttachedToParent 选项不起作用,并且子任务将作为分离的子任务执行。有关详细信息,请参阅附加和分离的子任务。 |
DenyChildAttach | 指定任何尝试作为附加的子任务执行(即,使用 AttachedToParent 选项创建)的子任务都无法附加到父任务,会改成作为分离的子任务执行。 有关详细信息,请参阅附加和分离的子任务。 |
HideScheduler | 防止环境计划程序被视为已创建任务的当前计划程序。 这意味着像 StartNew 或 ContinueWith 创建任务的执行操作将被视为 Default 当前计划程序。 |
LongRunning | 指定任务将是长时间运行的、粗粒度的操作,涉及比细化的系统更少、更大的组件。 它会向 TaskScheduler 提示,过度订阅可能是合理的。 可以通过过度订阅创建比可用硬件线程数更多的线程。 它还将提示任务计划程序:该任务需要附加线程,以使任务不阻塞本地线程池队列中其他线程或工作项的向前推动。 |
None | 指定应使用默认行为。 |
PreferFairness | 提示 TaskScheduler 以一种尽可能公平的方式安排任务,这意味着较早安排的任务将更可能较早运行,而较晚安排运行的任务将更可能较晚运行。 |
RunContinuationsAsynchronously | 强制异步执行添加到当前任务的延续任务。请注意,RunContinuationsAsynchronously 成员在以 .NET Framework 4.6 开头的 TaskCreationOptions 枚举中可用。 |
static void Main(string[] args)
{
// 使用构造方法创建任务
var t1 = new Task(() => TaskMethod("Task 1"));
var t2 = new Task(() => TaskMethod("Task 2"));
// 需要手动启动
t2.Start();
t1.Start();
// 使用Task.Run 方法启动任务 不需要手动启动
Task.Run(() => TaskMethod("Task 3"));
// 使用 Task.Factory.StartNew方法 启动任务 实际上就是Task.Run
Task.Factory.StartNew(() => TaskMethod("Task 4"));
// 在StartNew的基础上 添加 TaskCreationOptions.LongRunning 告诉 Factory该任务需要长时间运行
// 那么它就会可能会创建一个 非线程池线程来执行任务
Task.Factory.StartNew(() => TaskMethod("Task 5"), TaskCreationOptions.LongRunning);
ReadLine();
}
static void TaskMethod(string name)
{
WriteLine($"任务 {name} 运行,线程 id {CurrentThread.ManagedThreadId}. 是否为线程池线程: {CurrentThread.IsThreadPoolThread}.");
}
运行结果如下图所示。
1.3 使用任务执行基本的操作
在本节中,使用任务执行基本的操作,并且获取任务执行完成后的结果值。本节内容比较简单,在此不做过多介绍。
演示代码如下,在主线程中要获取结果值,常用的方式就是访问task.Result
属性,如果任务线程还没执行完毕,那么会阻塞主线程,直到线程执行完。如果任务线程执行完毕,那么将直接拿到运算的结果值。
在Task 3
中,使用了task.Status
来打印线程的状态,线程每个状态的具体含义,将在下一节中介绍。
static void Main(string[] args)
{
// 直接执行方法 作为参照
TaskMethod("主线程任务");
// 访问 Result属性 达到运行结果
Task<int> task = CreateTask("Task 1");
task.Start();
int result = task.Result;
WriteLine($"运算结果: {result}");
// 使用当前线程,同步执行任务
task = CreateTask("Task 2");
task.RunSynchronously();
result = task.Result;
WriteLine($"运算结果:{result}");
// 通过循环等待 获取运行结果
task = CreateTask("Task 3");
WriteLine(task.Status);
task.Start();
while (!task.IsCompleted)
{
WriteLine(task.Status);
Sleep(TimeSpan.FromSeconds(0.5));
}
WriteLine(task.Status);
result = task.Result;
WriteLine($"运算结果:{result}");
Console.ReadLine();
}
static Task<int> CreateTask(string name)
{
return new Task<int>(() => TaskMethod(name));
}
static int TaskMethod(string name)
{
WriteLine($"{name} 运行在线程 {CurrentThread.ManagedThreadId}上. 是否为线程池线程 {CurrentThread.IsThreadPoolThread}");
Sleep(TimeSpan.FromSeconds(2));
return 42;
}
运行结果如下,可见Task 1
和Task 2
均是运行在主线程上,并非线程池线程。
1.4 组合任务
在本节中,体现了任务其中一个强大的功能,那就是组合任务。通过组合任务可很好的描述任务与任务之间的异步、同步关系,大大降低了编程的难度。
组合任务主要是通过task.ContinueWith()
、task.WhenAny()
、task.WhenAll()
等和task.GetAwaiter().OnCompleted()
方法来实现。
在使用task.ContinueWith()
方法时,需要注意它也可传递一系列的枚举选项TaskContinuationOptions
,该枚举选项和TaskCreationOptions
类似,其具体定义如下表所示。
成员名称 | 说明 |
---|---|
AttachedToParent | 如果延续为子任务,则指定将延续附加到任务层次结构中的父级。 只有当延续前面的任务也是子任务时,延续才可以是子任务。 默认情况下,子任务(即由外部任务创建的内部任务)将独立于其父任务执行。 可以使用 TaskContinuationOptions.AttachedToParent 选项以便将父任务和子任务同步。请注意,如果使用 DenyChildAttach 选项配置父任务,则子任务中的 AttachedToParent 选项不起作用,并且子任务将作为分离的子任务执行。有关更多信息,请参见Attached and Detached Child Tasks。 |
DenyChildAttach | 指定任何使用 TaskCreationOptions.AttachedToParent 选项创建,并尝试作为附加的子任务执行的子任务(即,由此延续创建的任何嵌套内部任务)都无法附加到父任务,会改成作为分离的子任务执行。 有关详细信息,请参阅附加和分离的子任务。 |
ExecuteSynchronously | 指定应同步执行延续任务。 指定此选项后,延续任务在导致前面的任务转换为其最终状态的相同线程上运行。如果在创建延续任务时已经完成前面的任务,则延续任务将在创建此延续任务的线程上运行。 如果前面任务的 CancellationTokenSource 已在一个 finally(在 Visual Basic 中为 Finally)块中释放,则使用此选项的延续任务将在该 finally 块中运行。 只应同步执行运行时间非常短的延续任务。由于任务以同步方式执行,因此无需调用诸如 Task.Wait 的方法来确保调用线程等待任务完成。 |
HideScheduler | 指定由延续通过调用方法(如 Task.Run 或 Task.ContinueWith)创建的任务将默认计划程序 (TaskScheduler.Default) 视为当前的计划程序,而不是正在运行该延续的计划程序。 |
LazyCancellation | 在延续取消的情况下,防止延续的完成直到完成先前的任务。 |
LongRunning | 指定延续将是长期运行的、粗粒度的操作。 它会向 TaskScheduler 提示,过度订阅可能是合理的。 |
None | 如果未指定延续选项,应在执行延续任务时使用指定的默认行为。 延续任务在前面的任务完成后以异步方式运行,与前面任务最终的 Task.Status 属性值无关。 如果延续为子任务,则会将其创建为分离的嵌套任务。 |
NotOnCanceled | 指定不应在延续任务前面的任务已取消的情况下安排延续任务。 如果前面任务完成的 Task.Status 属性是 TaskStatus.Canceled,则前面的任务会取消。 此选项对多任务延续无效。 |
NotOnFaulted | 指定不应在延续任务前面的任务引发了未处理异常的情况下安排延续任务。 如果前面任务完成的 Task.Status 属性是 TaskStatus.Faulted,则前面的任务会引发未处理的异常。 此选项对多任务延续无效。 |
NotOnRanToCompletion | 指定不应在延续任务前面的任务已完成运行的情况下安排延续任务。 如果前面任务完成的 Task.Status 属性是 TaskStatus.RanToCompletion,则前面的任务会运行直至完成。 此选项对多任务延续无效。 |
OnlyOnCanceled | 指定只应在延续前面的任务已取消的情况下安排延续任务。 如果前面任务完成的 Task.Status 属性是 TaskStatus.Canceled,则前面的任务会取消。 此选项对多任务延续无效。 |
OnlyOnFaulted | 指定只有在延续任务前面的任务引发了未处理异常的情况下才应安排延续任务。 如果前面任务完成的 Task.Status 属性是 TaskStatus.Faulted,则前面的任务会引发未处理的异常。OnlyOnFaulted 选项可保证前面任务中的 Task.Exception 属性不是 null。 你可以使用该属性来捕获异常,并确定导致任务出错的异常。 如果你不访问 Exception 属性,则不会处理异常。 此外,如果尝试访问已取消或出错的任务的 Result 属性,则会引发一个新异常。此选项对多任务延续无效。 |
OnlyOnRanToCompletion | 指定只应在延续任务前面的任务已完成运行的情况下才安排延续任务。 如果前面任务完成的 Task.Status 属性是 TaskStatus.RanToCompletion,则前面的任务会运行直至完成。 此选项对多任务延续无效。 |
PreferFairness | 提示 TaskScheduler 按任务计划的顺序安排任务,因此较早安排的任务将更可能较早运行,而较晚安排运行的任务将更可能较晚运行。 |
RunContinuationsAsynchronously | 指定应异步运行延续任务。 此选项优先于 TaskContinuationOptions.ExecuteSynchronously。 |
演示代码如下所示,使用ContinueWith()
和OnCompleted()
方法组合了任务来运行,搭配不同的TaskCreationOptions
和TaskContinuationOptions
来实现不同的效果。
static void Main(string[] args)
{
WriteLine($"主线程 线程 Id {CurrentThread.ManagedThreadId}");
// 创建两个任务
var firstTask = new Task<int>(() => TaskMethod("Frist Task",3));
var secondTask = new Task<int>(()=> TaskMethod("Second Task",2));
// 在默认的情况下 ContiueWith会在前面任务运行后再运行
firstTask.ContinueWith(t => WriteLine($"第一次运行答案是 {t.Result}. 线程Id {CurrentThread.ManagedThreadId}. 是否为线程池线程: {CurrentThread.IsThreadPoolThread}"));
// 启动任务
firstTask.Start();
secondTask.Start();
Sleep(TimeSpan.FromSeconds(4));
// 这里会紧接着 Second Task运行后运行, 但是由于添加了 OnlyOnRanToCompletion 和 ExecuteSynchronously 所以会由运行SecondTask的线程来 运行这个任务
Task continuation = secondTask.ContinueWith(t => WriteLine($"第二次运行的答案是 {t.Result}. 线程Id {CurrentThread.ManagedThreadId}. 是否为线程池线程:{CurrentThread.IsThreadPoolThread}"),TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously);
// OnCompleted 是一个事件 当contiuation运行完成后 执行OnCompleted Action事件
continuation.GetAwaiter().OnCompleted(() => WriteLine($"后继任务完成. 线程Id {CurrentThread.ManagedThreadId}. 是否为线程池线程 {CurrentThread.IsThreadPoolThread}"));
Sleep(TimeSpan.FromSeconds(2));
WriteLine();
firstTask = new Task<int>(() =>
{
// 使用了TaskCreationOptions.AttachedToParent 将这个Task和父Task关联, 当这个Task没有结束时 父Task 状态为 WaitingForChildrenToComplete
var innerTask = Task.Factory.StartNew(() => TaskMethod("Second Task",5), TaskCreationOptions.AttachedToParent);
innerTask.ContinueWith(t => TaskMethod("Thrid Task", 2), TaskContinuationOptions.AttachedToParent);
return TaskMethod("First Task",2);
});
firstTask.Start();
// 检查firstTask线程状态 根据上面的分析 首先是 Running -> WatingForChildrenToComplete -> RanToCompletion
while (! firstTask.IsCompleted)
{
WriteLine(firstTask.Status);
Sleep(TimeSpan.FromSeconds(0.5));
}
WriteLine(firstTask.Status);
Console.ReadLine();
}
static int TaskMethod(string name, int seconds)
{
WriteLine($"任务 {name} 正在运行,线程池线程 Id {CurrentThread.ManagedThreadId},是否为线程池线程: {CurrentThread.IsThreadPoolThread}");
Sleep(TimeSpan.FromSeconds(seconds));
return 42 * seconds;
}
运行结果如下图所示,与预期结果一致。其中使用了task.Status
来打印任务运行的状态,对于task.Status
的状态具体含义如下表所示。
成员名称 | 说明 |
---|---|
Canceled | 该任务已通过对其自身的 CancellationToken 引发 OperationCanceledException 对取消进行了确认,此时该标记处于已发送信号状态;或者在该任务开始执行之前,已向该任务的 CancellationToken 发出了信号。 有关详细信息,请参阅任务取消。 |
Created | 该任务已初始化,但尚未被计划。 |
Faulted | 由于未处理异常的原因而完成的任务。 |
RanToCompletion | 已成功完成执行的任务。 |
Running | 该任务正在运行,但尚未完成。 |
WaitingForActivation | 该任务正在等待 .NET Framework 基础结构在内部将其激活并进行计划。 |
WaitingForChildrenToComplete | 该任务已完成执行,正在隐式等待附加的子任务完成。 |
WaitingToRun | 该任务已被计划执行,但尚未开始执行。 |
1.5 将APM模式转换为任务
在前面的章节中,介绍了基于IAsyncResult
接口实现了BeginXXXX/EndXXXX
方法的就叫APM模式。APM模式非常古老,那么如何将它转换为TAP模式呢?对于常见的几种APM模式异步任务,我们一般选择使用Task.Factory.FromAsync()
方法来实现将APM模式转换为TAP模式。
演示代码如下所示,比较简单不作过多介绍。
static void Main(string[] args)
{
int threadId;
AsynchronousTask d = Test;
IncompatibleAsychronousTask e = Test;
// 使用 Task.Factory.FromAsync方法 转换为Task
WriteLine("Option 1");
Task<string> task = Task<string>.Factory.FromAsync(d.BeginInvoke("异步任务线程", CallBack, "委托异步调用"), d.EndInvoke);
task.ContinueWith(t => WriteLine($"回调函数执行完毕,现在运行续接函数!结果:{t.Result}"));
while (!task.IsCompleted)
{
WriteLine(task.Status);
Sleep(TimeSpan.FromSeconds(0.5));
}
WriteLine(task.Status);
Sleep(TimeSpan.FromSeconds(1));
WriteLine("----------------------------------------------");
WriteLine();
// 使用 Task.Factory.FromAsync重载方法 转换为Task
WriteLine("Option 2");
task = Task<string>.Factory.FromAsync(d.BeginInvoke,d.EndInvoke,"异步任务线程","委托异步调用");
task.ContinueWith(t => WriteLine($"任务完成,现在运行续接函数!结果:{t.Result}"));
while (!task.IsCompleted)
{
WriteLine(task.Status);
Sleep(TimeSpan.FromSeconds(0.5));
}
WriteLine(task.Status);
Sleep(TimeSpan.FromSeconds(1));
WriteLine("----------------------------------------------");
WriteLine();
// 同样可以使用 FromAsync方法 将 BeginInvoke 转换为 IAsyncResult 最后转换为 Task
WriteLine("Option 3");
IAsyncResult ar = e.BeginInvoke(out threadId, CallBack, "委托异步调用");
task = Task<string>.Factory.FromAsync(ar, _ => e.EndInvoke(out threadId, ar));
task.ContinueWith(t => WriteLine($"任务完成,现在运行续接函数!结果:{t.Result},线程Id {threadId}"));
while (!task.IsCompleted)
{
WriteLine(task.Status);
Sleep(TimeSpan.FromSeconds(0.5));
}
WriteLine(task.Status);
ReadLine();
}
delegate string AsynchronousTask(string threadName);
delegate string IncompatibleAsychronousTask(out int threadId);
static void CallBack(IAsyncResult ar)
{
WriteLine("开始运行回调函数...");
WriteLine($"传递给回调函数的状态{ar.AsyncState}");
WriteLine($"是否为线程池线程:{CurrentThread.IsThreadPoolThread}");
WriteLine($"线程池工作线程Id:{CurrentThread.ManagedThreadId}");
}
static string Test(string threadName)
{
WriteLine("开始运行...");
WriteLine($"是否为线程池线程:{CurrentThread.IsThreadPoolThread}");
Sleep(TimeSpan.FromSeconds(2));
CurrentThread.Name = threadName;
return $"线程名:{CurrentThread.Name}";
}
static string Test(out int threadId)
{
WriteLine("开始运行...");
WriteLine($"是否为线程池线程:{CurrentThread.IsThreadPoolThread}");
Sleep(TimeSpan.FromSeconds(2));
threadId = CurrentThread.ManagedThreadId;
return $"线程池线程工作Id是:{threadId}";
}
运行结果如下图所示。
1.6 将EAP模式转换为任务
在上几章中有提到,通过BackgroundWorker
类通过事件的方式实现的异步,我们叫它EAP模式。那么如何将EAP模式转换为任务呢?很简单,我们只需要通过TaskCompletionSource
类,即可将EAP模式转换为任务。
演示代码如下所示。
static void Main(string[] args)
{
var tcs = new TaskCompletionSource<int>();
var worker = new BackgroundWorker();
worker.DoWork += (sender, eventArgs) =>
{
eventArgs.Result = TaskMethod("后台工作", 5);
};
// 通过此方法 将EAP模式转换为 任务
worker.RunWorkerCompleted += (sender, eventArgs) =>
{
if (eventArgs.Error != null)
{
tcs.SetException(eventArgs.Error);
}
else if (eventArgs.Cancelled)
{
tcs.SetCanceled();
}
else
{
tcs.SetResult((int)eventArgs.Result);
}
};
worker.RunWorkerAsync();
// 调用结果
int result = tcs.Task.Result;
WriteLine($"结果是:{result}");
ReadLine();
}
static int TaskMethod(string name, int seconds)
{
WriteLine($"任务{name}运行在线程{CurrentThread.ManagedThreadId}上. 是否为线程池线程{CurrentThread.IsThreadPoolThread}");
Sleep(TimeSpan.FromSeconds(seconds));
return 42 * seconds;
}
运行结果如下图所示。
1.7 实现取消选项
在TAP模式中,实现取消选项和之前的异步模式一样,都是使用CancellationToken
来实现,但是不同的是Task构造函数允许传入一个CancellationToken
,从而在任务实际启动之前取消它。
演示代码如下所示。
static void Main(string[] args)
{
var cts = new CancellationTokenSource();
// new Task时 可以传入一个 CancellationToken对象 可以在线程创建时 变取消任务
var longTask = new Task<int>(() => TaskMethod("Task 1", 10, cts.Token), cts.Token);
WriteLine(longTask.Status);
cts.Cancel();
WriteLine(longTask.Status);
WriteLine("第一个任务在运行前被取消.");
// 同样的 可以通过CancellationToken对象 取消正在运行的任务
cts = new CancellationTokenSource();
longTask = new Task<int>(() => TaskMethod("Task 2", 10, cts.Token), cts.Token);
longTask.Start();
for (int i = 0; i < 5; i++)
{
Sleep(TimeSpan.FromSeconds(0.5));
WriteLine(longTask.Status);
}
cts.Cancel();
for (int i = 0; i < 5; i++)
{
Sleep(TimeSpan.FromSeconds(0.5));
WriteLine(longTask.Status);
}
WriteLine($"这个任务已完成,结果为{longTask.Result}");
ReadLine();
}
static int TaskMethod(string name, int seconds, CancellationToken token)
{
WriteLine($"任务运行在{CurrentThread.ManagedThreadId}上. 是否为线程池线程:{CurrentThread.IsThreadPoolThread}");
for (int i = 0; i < seconds; i++)
{
Sleep(TimeSpan.FromSeconds(1));
if (token.IsCancellationRequested)
{
return -1;
}
}
return 42 * seconds;
}
运行结果如下图所示,这里需要注意的是,如果是在任务执行之前取消了任务,那么它的最终状态是Canceled
。如果是在执行过程中取消任务,那么它的状态是RanCompletion
。
1.8 处理任务中的异常
在任务中,处理异常和其它异步方式处理异常类似,如果能在所发生异常的线程中处理,那么不要在其它地方处理。但是对于一些不可预料的异常,那么可以通过几种方式来处理。
可以通过访问task.Result
属性来处理异常,因为访问这个属性的Get
方法会使当前线程等待直到该任务完成,并将异常传播给当前线程,这样就可以通过try catch
语句块来捕获异常。另外使用task.GetAwaiter().GetResult()
方法和第使用task.Result
类似,同样可以捕获异常。如果是要捕获多个任务中的异常错误,那么可以通过ContinueWith()
方法来处理。
具体如何实现,演示代码如下所示。
static void Main(string[] args)
{
Task<int> task;
// 在主线程中调用 task.Result task中的异常信息会直接抛出到 主线程中
try
{
task = Task.Run(() => TaskMethod("Task 1", 2));
int result = task.Result;
WriteLine($"结果为: {result}");
}
catch (Exception ex)
{
WriteLine($"异常被捕捉:{ex.Message}");
}
WriteLine("------------------------------------------------");
WriteLine();
// 同上 只是访问Result的方式不同
try
{
task = Task.Run(() => TaskMethod("Task 2", 2));
int result = task.GetAwaiter().GetResult();
WriteLine($"结果为:{result}");
}
catch (Exception ex)
{
WriteLine($"异常被捕捉: {ex.Message}");
}
WriteLine("----------------------------------------------");
WriteLine();
var t1 = new Task<int>(() => TaskMethod("Task 3", 3));
var t2 = new Task<int>(() => TaskMethod("Task 4", 4));
var complexTask = Task.WhenAll(t1, t2);
// 通过ContinueWith TaskContinuationOptions.OnlyOnFaulted的方式 如果task出现异常 那么才会执行该方法
var exceptionHandler = complexTask.ContinueWith(t => {
WriteLine($"异常被捕捉:{t.Exception.Message}");
foreach (var ex in t.Exception.InnerExceptions)
{
WriteLine($"-------------------------- {ex.Message}");
}
},TaskContinuationOptions.OnlyOnFaulted);
t1.Start();
t2.Start();
ReadLine();
}
static int TaskMethod(string name, int seconds)
{
WriteLine($"任务运行在{CurrentThread.ManagedThreadId}上. 是否为线程池线程:{CurrentThread.IsThreadPoolThread}");
Sleep(TimeSpan.FromSeconds(seconds));
// 人为抛出一个异常
throw new Exception("Boom!");
return 42 * seconds;
}
运行结果如下所示,需要注意的是,如果在ContinueWith()
方法中捕获多个任务产生的异常,那么它的异常类型是AggregateException
,具体的异常信息包含在InnerExceptions
里面,要注意和InnerException
区分。
1.9 并行运行任务
本节中主要介绍了两个方法的使用,一个是等待组中全部任务都执行结束的Task.WhenAll()
方法,另一个是只要组中一个方法执行结束都执行的Task.WhenAny()
方法。
具体使用,如下演示代码所示。
static void Main(string[] args)
{
// 第一种方式 通过Task.WhenAll 等待所有任务运行完成
var firstTask = new Task<int>(() => TaskMethod("First Task", 3));
var secondTask = new Task<int>(() => TaskMethod("Second Task", 2));
// 当firstTask 和 secondTask 运行完成后 才执行 whenAllTask的ContinueWith
var whenAllTask = Task.WhenAll(firstTask, secondTask);
whenAllTask.ContinueWith(t => WriteLine($"第一个任务答案为{t.Result[0]},第二个任务答案为{t.Result[1]}"), TaskContinuationOptions.OnlyOnRanToCompletion);
firstTask.Start();
secondTask.Start();
Sleep(TimeSpan.FromSeconds(4));
// 使用WhenAny方法 只要列表中有一个任务完成 那么该方法就会取出那个完成的任务
var tasks = new List<Task<int>>();
for (int i = 0; i < 4; i++)
{
int counter = 1;
var task = new Task<int>(() => TaskMethod($"Task {counter}",counter));
tasks.Add(task);
task.Start();
}
while (tasks.Count > 0)
{
var completedTask = Task.WhenAny(tasks).Result;
tasks.Remove(completedTask);
WriteLine($"一个任务已经完成,结果为 {completedTask.Result}");
}
ReadLine();
}
static int TaskMethod(string name, int seconds)
{
WriteLine($"任务运行在{CurrentThread.ManagedThreadId}上. 是否为线程池线程:{CurrentThread.IsThreadPoolThread}");
Sleep(TimeSpan.FromSeconds(seconds));
return 42 * seconds;
}
运行结果如下图所示。
1.10 使用TaskScheduler配置任务执行
在Task
中,负责任务调度是TaskScheduler
对象,FCL提供了两个派生自TaskScheduler
的类型:线程池任务调度器(Thread Pool Task Scheduler)和同步上下文任务调度器(Synchronization Scheduler)。默认情况下所有应用程序都使用线程池任务调度器,但是在UI组件中,不使用线程池中的线程,避免跨线程更新UI,需要使用同步上下文任务调度器。可以通过执行TaskScheduler
的FromCurrentSynchronizationContext()
静态方法来获得对同步上下文任务调度器的引用。
演示程序如下所示,为了延时同步上下文任务调度器,我们此次使用WPF来创建项目。
MainWindow.xaml 代码如下所示。
<Window x:Class="Recipe9.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Recipe9"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid>
<TextBlock Name="ContentTextBlock" HorizontalAlignment="Left" Margin="44,134,0,0" VerticalAlignment="Top" Width="425" Height="40"/>
<Button Content="Sync" HorizontalAlignment="Left" Margin="45,190,0,0" VerticalAlignment="Top" Width="75" Click="ButtonSync_Click"/>
<Button Content="Async" HorizontalAlignment="Left" Margin="165,190,0,0" VerticalAlignment="Top" Width="75" Click="ButtonAsync_Click"/>
<Button Content="Async OK" HorizontalAlignment="Left" Margin="285,190,0,0" VerticalAlignment="Top" Width="75" Click="ButtonAsyncOK_Click"/>
</Grid>
</Window>
MainWindow.xaml.cs 代码如下所示。
/// <summary>
/// MainWindow.xaml 的交互逻辑
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
// 同步执行 计算密集任务 导致UI线程阻塞
private void ButtonSync_Click(object sender, RoutedEventArgs e)
{
ContentTextBlock.Text = string.Empty;
try
{
string result = TaskMethod().Result;
ContentTextBlock.Text = result;
}
catch (Exception ex)
{
ContentTextBlock.Text = ex.InnerException.Message;
}
}
// 异步的方式来执行 计算密集任务 UI线程不会阻塞 但是 不能跨线程更新UI 所以会有异常
private void ButtonAsync_Click(object sender, RoutedEventArgs e)
{
ContentTextBlock.Text = string.Empty;
Mouse.OverrideCursor = Cursors.Wait;
Task<string> task = TaskMethod();
task.ContinueWith(t => {
ContentTextBlock.Text = t.Exception.InnerException.Message;
Mouse.OverrideCursor = null;
}, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.FromCurrentSynchronizationContext());
}
// 通过 异步 和 FromCurrentSynchronizationContext方法 创建了线程同步的上下文 没有跨线程更新UI
private void ButtonAsyncOK_Click(object sender, RoutedEventArgs e)
{
ContentTextBlock.Text = string.Empty;
Mouse.OverrideCursor = Cursors.Wait;
Task<string> task = TaskMethod(TaskScheduler.FromCurrentSynchronizationContext());
task.ContinueWith(t => Mouse.OverrideCursor = null,
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.FromCurrentSynchronizationContext());
}
Task<string> TaskMethod()
{
return TaskMethod(TaskScheduler.Default);
}
Task<string> TaskMethod(TaskScheduler scheduler)
{
Task delay = Task.Delay(TimeSpan.FromSeconds(5));
return delay.ContinueWith(t =>
{
string str = $"任务运行在{CurrentThread.ManagedThreadId}上. 是否为线程池线程:{CurrentThread.IsThreadPoolThread}";
Console.WriteLine(str);
ContentTextBlock.Text = str;
return str;
}, scheduler);
}
}
运行结果如下所示,从左至右依次单击按钮,前两个按钮将会引发异常。
具体信息如下所示。
参考书籍
本文主要参考了以下几本书,在此对这些作者表示由衷的感谢,感谢你们为.Net的发扬光大所做的贡献!
- 《CLR via C#》
- 《C# in Depth Third Edition》
- 《Essential C# 6.0》
- 《Multithreading with C# Cookbook Second Edition》
- 《C#多线程编程实战》
C#多线程编程系列(三)- 线程同步
目录
- 1.1 简介
- 1.2 执行基本原子操作
- 1.3 使用Mutex类
- 1.4 使用SemaphoreSlim类
- 1.5 使用AutoResetEvent类
- 1.6 使用ManualResetEventSlim类
- 1.7 使用CountDownEvent类
- 1.8 使用Barrier类
- 1.9 使用ReaderWriterLockSlim类
- 1.10 使用SpinWait类
- 参考书籍
- 笔者水平有限,如果错误欢迎各位批评指正!
1.1 简介
本章介绍在C#中实现线程同步的几种方法。因为多个线程同时访问共享数据时,可能会造成共享数据的损坏,从而导致与预期的结果不相符。为了解决这个问题,所以需要用到线程同步,也被俗称为“加锁”。但是加锁绝对不对提高性能,最多也就是不增不减,要实现性能不增不减还得靠高质量的同步源语(Synchronization Primitive)。但是因为正确永远比速度更重要,所以线程同步在某些场景下是必须的。
线程同步有两种源语(Primitive)构造:用户模式(user - mode)和内核模式(kernel - mode),当资源可用时间短的情况下,用户模式要优于内核模式,但是如果长时间不能获得资源,或者说长时间处于“自旋”,那么内核模式是相对来说好的选择。
但是我们希望兼具用户模式和内核模式的优点,我们把它称为混合构造(hybrid construct),它兼具了两种模式的优点。
在C#中有多种线程同步的机制,通常可以按照以下顺序进行选择。
- 如果代码能通过优化可以不进行同步,那么就不要做同步。
- 使用原子性的
Interlocked
方法。- 使用
lock/Monitor
类。- 使用异步锁,如
SemaphoreSlim.WaitAsync()
。- 使用其它加锁机制,如
ReaderWriterLockSlim、Mutex、Semaphore
等。- 如果系统提供了
*Slim
版本的异步对象,那么请选用它,因为*Slim
版本全部都是混合锁,在进入内核模式前实现了某种形式的自旋。
在同步中,一定要注意避免死锁的发生,死锁的发生必须满足以下4个基本条件,所以只需要破坏任意一个条件,就可避免发生死锁。
- 排他或互斥(Mutual exclusion):一个线程(ThreadA)独占一个资源,没有其它线程(ThreadB)能获取相同的资源。
- 占有并等待(Hold and wait):互斥的一个线程(ThreadA)请求获取另一个线程(ThreadB)占有的资源.
- 不可抢先(No preemption):一个线程(ThreadA)占有资源不能被强制拿走(只能等待ThreadA主动释放它的资源)。
- 循环等待条件(Circular wait condition):两个或多个线程构成一个循环等待链,它们锁定两个或多个相同的资源,每个线程都在等待链中的下一个线程占有的资源。
1.2 执行基本原子操作
CLR保证了对这些数据类型的读写是原子性的:Boolean、Char、(S)Byte、(U)Int16、(U)Int32、(U)IntPtr和Single
。但是如果读写Int64
可能会发生读取撕裂(torn read)的问题,因为在32位操作系统中,它需要执行两次Mov
操作,无法在一个时间内执行完成。
那么在本节中,就会着重的介绍System.Threading.Interlocked
类提供的方法,Interlocked
类中的每个方法都是执行一次的读取以及写入操作。更多与Interlocked
类相关的资料请参考链接,戳一戳本文不在赘述。
演示代码如下所示,分别使用了三种方式进行计数:错误计数方式、lock
锁方式和Interlocked
原子方式。
private static void Main(string[] args)
{
Console.WriteLine("错误的计数");
var c = new Counter();
Execute(c);
Console.WriteLine("--------------------------");
Console.WriteLine("正确的计数 - 有锁");
var c2 = new CounterWithLock();
Execute(c2);
Console.WriteLine("--------------------------");
Console.WriteLine("正确的计数 - 无锁");
var c3 = new CounterNoLock();
Execute(c3);
Console.ReadLine();
}
static void Execute(CounterBase c)
{
// 统计耗时
var sw = new Stopwatch();
sw.Start();
var t1 = new Thread(() => TestCounter(c));
var t2 = new Thread(() => TestCounter(c));
var t3 = new Thread(() => TestCounter(c));
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
sw.Stop();
Console.WriteLine($"Total count: {c.Count} Time:{sw.ElapsedMilliseconds} ms");
}
static void TestCounter(CounterBase c)
{
for (int i = 0; i < 100000; i++)
{
c.Increment();
c.Decrement();
}
}
class Counter : CounterBase
{
public override void Increment()
{
_count++;
}
public override void Decrement()
{
_count--;
}
}
class CounterNoLock : CounterBase
{
public override void Increment()
{
// 使用Interlocked执行原子操作
Interlocked.Increment(ref _count);
}
public override void Decrement()
{
Interlocked.Decrement(ref _count);
}
}
class CounterWithLock : CounterBase
{
private readonly object _syncRoot = new Object();
public override void Increment()
{
// 使用Lock关键字 锁定私有变量
lock (_syncRoot)
{
// 同步块
Count++;
}
}
public override void Decrement()
{
lock (_syncRoot)
{
Count--;
}
}
}
abstract class CounterBase
{
protected int _count;
public int Count
{
get
{
return _count;
}
set
{
_count = value;
}
}
public abstract void Increment();
public abstract void Decrement();
}
运行结果如下所示,与预期结果基本相符。
1.3 使用Mutex类
System.Threading.Mutex
在概念上和System.Threading.Monitor
几乎一样,但是Mutex
同步对文件或者其他跨进程的资源进行访问,也就是说Mutex
是可跨进程的。因为其特性,它的一个用途是限制应用程序不能同时运行多个实例。
Mutex
对象支持递归,也就是说同一个线程可多次获取同一个锁,这在后面演示代码中可观察到。由于Mutex
的基类System.Theading.WaitHandle
实现了IDisposable
接口,所以当不需要在使用它时要注意进行资源的释放。更多资料:戳一戳
演示代码如下所示,简单的演示了如何创建单实例的应用程序和Mutex
递归获取锁的实现。
const string MutexName = "CSharpThreadingCookbook";
static void Main(string[] args)
{
// 使用using 及时释放资源
using (var m = new Mutex(false, MutexName))
{
if (!m.WaitOne(TimeSpan.FromSeconds(5), false))
{
Console.WriteLine("已经有实例正在运行!");
}
else
{
Console.WriteLine("运行中...");
// 演示递归获取锁
Recursion();
Console.ReadLine();
m.ReleaseMutex();
}
}
Console.ReadLine();
}
static void Recursion()
{
using (var m = new Mutex(false, MutexName))
{
if (!m.WaitOne(TimeSpan.FromSeconds(2), false))
{
// 因为Mutex支持递归获取锁 所以永远不会执行到这里
Console.WriteLine("递归获取锁失败!");
}
else
{
Console.WriteLine("递归获取锁成功!");
}
}
}
运行结果如下图所示,打开了两个应用程序,因为使用Mutex
实现了单实例,所以第二个应用程序无法获取锁,就会显示已有实例正在运行。
1.4 使用SemaphoreSlim类
SemaphoreSlim
类与之前提到的同步类有锁不同,之前提到的同步类都是互斥的,也就是说只允许一个线程进行访问资源,而SemaphoreSlim
是可以允许多个访问。
在之前的部分有提到,以*Slim
结尾的线程同步类,都是工作在混合模式下的,也就是说开始它们都是在用户模式下"自旋",等发生第一次竞争时,才切换到内核模式。但是SemaphoreSlim
不同于Semaphore
类,它不支持系统信号量,所以它不能用于进程之间的同步。
该类使用比较简单,演示代码演示了6个线程竞争访问只允许4个线程同时访问的数据库,如下所示。
static void Main(string[] args)
{
// 创建6个线程 竞争访问AccessDatabase
for (int i = 1; i <= 6; i++)
{
string threadName = "线程 " + i;
// 越后面的线程,访问时间越久 方便查看效果
int secondsToWait = 2 + 2 * i;
var t = new Thread(() => AccessDatabase(threadName, secondsToWait));
t.Start();
}
Console.ReadLine();
}
// 同时允许4个线程访问
static SemaphoreSlim _semaphore = new SemaphoreSlim(4);
static void AccessDatabase(string name, int seconds)
{
Console.WriteLine($"{name} 等待访问数据库.... {DateTime.Now.ToString("HH:mm:ss.ffff")}");
// 等待获取锁 进入临界区
_semaphore.Wait();
Console.WriteLine($"{name} 已获取对数据库的访问权限 {DateTime.Now.ToString("HH:mm:ss.ffff")}");
// Do something
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine($"{name} 访问完成... {DateTime.Now.ToString("HH:mm:ss.ffff")}");
// 释放锁
_semaphore.Release();
}
运行结果如下所示,可见前4个线程马上就获取到了锁,进入了临界区,而另外两个线程在等待;等有锁被释放时,才能进入临界区。
1.5 使用AutoResetEvent类
AutoResetEvent
叫自动重置事件,虽然名称中有事件一词,但是重置事件和C#中的委托没有任何关系,这里的事件只是由内核维护的Boolean
变量,当事件为false
,那么在事件上等待的线程就阻塞;事件变为true
,那么阻塞解除。
在.Net中有两种此类事件,即AutoResetEvent(自动重置事件)
和ManualResetEvent(手动重置事件)
。这两者均是采用内核模式,它的区别在于当重置事件为true
时,自动重置事件它只唤醒一个阻塞的线程,会自动将事件重置回false,造成其它线程继续阻塞。而手动重置事件不会自动重置,必须通过代码手动重置回false。
因为以上的原因,所以在很多文章和书籍中不推荐使用AutoResetEvent(自动重置事件)
,因为它很容易在编写生产者线程时发生失误,造成它的迭代次数多余消费者线程。
演示代码如下所示,该代码演示了通过AutoResetEvent
实现两个线程的互相同步。
static void Main(string[] args)
{
var t = new Thread(() => Process(10));
t.Start();
Console.WriteLine("等待另一个线程完成工作!");
// 等待工作线程通知 主线程阻塞
_workerEvent.WaitOne();
Console.WriteLine("第一个操作已经完成!");
Console.WriteLine("在主线程上执行操作");
Thread.Sleep(TimeSpan.FromSeconds(5));
// 发送通知 工作线程继续运行
_mainEvent.Set();
Console.WriteLine("现在在第二个线程上运行第二个操作");
// 等待工作线程通知 主线程阻塞
_workerEvent.WaitOne();
Console.WriteLine("第二次操作完成!");
Console.ReadLine();
}
// 工作线程Event
private static AutoResetEvent _workerEvent = new AutoResetEvent(false);
// 主线程Event
private static AutoResetEvent _mainEvent = new AutoResetEvent(false);
static void Process(int seconds)
{
Console.WriteLine("开始长时间的工作...");
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine("工作完成!");
// 发送通知 主线程继续运行
_workerEvent.Set();
Console.WriteLine("等待主线程完成其它工作");
// 等待主线程通知 工作线程阻塞
_mainEvent.WaitOne();
Console.WriteLine("启动第二次操作...");
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine("工作完成!");
// 发送通知 主线程继续运行
_workerEvent.Set();
}
运行结果如下图所示,与预期结果符合。
1.6 使用ManualResetEventSlim类
ManualResetEventSlim
使用和ManualResetEvent
类基本一致,只是ManualResetEventSlim
工作在混合模式下,而它与AutoResetEventSlim
不同的地方就是需要手动重置事件,也就是调用Reset()
才能将事件重置为false
。
演示代码如下,形象的将ManualResetEventSlim
比喻成大门,当事件为true
时大门打开,线程解除阻塞;而事件为false
时大门关闭,线程阻塞。
static void Main(string[] args)
{
var t1 = new Thread(() => TravelThroughGates("Thread 1", 5));
var t2 = new Thread(() => TravelThroughGates("Thread 2", 6));
var t3 = new Thread(() => TravelThroughGates("Thread 3", 12));
t1.Start();
t2.Start();
t3.Start();
// 休眠6秒钟 只有Thread 1小于 6秒钟,所以事件重置时 Thread 1 肯定能进入大门 而 Thread 2 可能可以进入大门
Thread.Sleep(TimeSpan.FromSeconds(6));
Console.WriteLine($"大门现在打开了! 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
_mainEvent.Set();
// 休眠2秒钟 此时 Thread 2 肯定可以进入大门
Thread.Sleep(TimeSpan.FromSeconds(2));
_mainEvent.Reset();
Console.WriteLine($"大门现在关闭了! 时间:{DateTime.Now.ToString("mm: ss.ffff")}");
// 休眠10秒钟 Thread 3 可以进入大门
Thread.Sleep(TimeSpan.FromSeconds(10));
Console.WriteLine($"大门现在第二次打开! 时间:{DateTime.Now.ToString("mm: ss.ffff")}");
_mainEvent.Set();
Thread.Sleep(TimeSpan.FromSeconds(2));
Console.WriteLine($"大门现在关闭了! 时间:{DateTime.Now.ToString("mm: ss.ffff")}");
_mainEvent.Reset();
Console.ReadLine();
}
static void TravelThroughGates(string threadName, int seconds)
{
Console.WriteLine($"{threadName} 进入睡眠 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine($"{threadName} 等待大门打开! 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
_mainEvent.Wait();
Console.WriteLine($"{threadName} 进入大门! 时间:{DateTime.Now.ToString("mm:ss.ffff")}");
}
static ManualResetEventSlim _mainEvent = new ManualResetEventSlim(false);
运行结果如下,与预期结果相符。
1.7 使用CountDownEvent类
CountDownEvent
类内部构造使用了一个ManualResetEventSlim
对象。这个构造阻塞一个线程,直到它内部计数器(CurrentCount)
变为0
时,才解除阻塞。也就是说它并不是阻止对已经枯竭的资源池的访问,而是只有当计数为0
时才允许访问。
这里需要注意的是,当CurrentCount
变为0
时,那么它就不能被更改了。为0
以后,Wait()
方法的阻塞被解除。
演示代码如下所示,只有当Signal()
方法被调用2次以后,Wait()
方法的阻塞才被解除。
static void Main(string[] args)
{
Console.WriteLine($"开始两个操作 {DateTime.Now.ToString("mm:ss.ffff")}");
var t1 = new Thread(() => PerformOperation("操作 1 完成!", 4));
var t2 = new Thread(() => PerformOperation("操作 2 完成!", 8));
t1.Start();
t2.Start();
// 等待操作完成
_countdown.Wait();
Console.WriteLine($"所有操作都完成 {DateTime.Now.ToString("mm: ss.ffff")}");
_countdown.Dispose();
Console.ReadLine();
}
// 构造函数的参数为2 表示只有调用了两次 Signal方法 CurrentCount 为 0时 Wait的阻塞才解除
static CountdownEvent _countdown = new CountdownEvent(2);
static void PerformOperation(string message, int seconds)
{
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine($"{message} {DateTime.Now.ToString("mm:ss.ffff")}");
// CurrentCount 递减 1
_countdown.Signal();
}
运行结果如下图所示,可见只有当操作1和操作2都完成以后,才执行输出所有操作都完成。
1.8 使用Barrier类
Barrier
类用于解决一个非常稀有的问题,平时一般用不上。Barrier
类控制一系列线程进行阶段性的并行工作。
假设现在并行工作分为2个阶段,每个线程在完成它自己那部分阶段1的工作后,必须停下来等待其它线程完成阶段1的工作;等所有线程均完成阶段1工作后,每个线程又开始运行,完成阶段2工作,等待其它线程全部完成阶段2工作后,整个流程才结束。
演示代码如下所示,该代码演示了两个线程分阶段的完成工作。
static void Main(string[] args)
{
var t1 = new Thread(() => PlayMusic("钢琴家", "演奏一首令人惊叹的独奏曲", 5));
var t2 = new Thread(() => PlayMusic("歌手", "唱着他的歌", 2));
t1.Start();
t2.Start();
Console.ReadLine();
}
static Barrier _barrier = new Barrier(2,
Console.WriteLine($"第 {b.CurrentPhaseNumber + 1} 阶段结束"));
static void PlayMusic(string name, string message, int seconds)
{
for (int i = 1; i < 3; i++)
{
Console.WriteLine("----------------------------------------------");
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine($"{name} 开始 {message}");
Thread.Sleep(TimeSpan.FromSeconds(seconds));
Console.WriteLine($"{name} 结束 {message}");
_barrier.SignalAndWait();
}
}
运行结果如下所示,当“歌手”线程完成后,并没有马上结束,而是等待“钢琴家”线程结束,当"钢琴家"线程结束后,才开始第2阶段的工作。
1.9 使用ReaderWriterLockSlim类
ReaderWriterLockSlim
类主要是解决在某些场景下,读操作多于写操作而使用某些互斥锁当多个线程同时访问资源时,只有一个线程能访问,导致性能急剧下降。
如果所有线程都希望以只读的方式访问数据,就根本没有必要阻塞它们;如果一个线程希望修改数据,那么这个线程才需要独占访问,这就是ReaderWriterLockSlim
的典型应用场景。这个类就像下面这样来控制线程。
- 一个线程向数据写入是,请求访问的其他所有线程都被阻塞。
- 一个线程读取数据时,请求读取的线程允许读取,而请求写入的线程被阻塞。
- 写入线程结束后,要么解除一个写入线程的阻塞,使写入线程能向数据接入,要么解除所有读取线程的阻塞,使它们能并发读取数据。如果线程没有被阻塞,锁就可以进入*使用的状态,可供下一个读线程或写线程获取。
- 从数据读取的所有线程结束后,一个写线程被解除阻塞,使它能向数据写入。如果线程没有被阻塞,锁就可以进入*使用的状态,可供下一个读线程或写线程获取。
ReaderWriterLockSlim
还支持从读线程升级为写线程的操作,详情请戳一戳。文本不作介绍。ReaderWriterLock
类已经过时,而且存在许多问题,没有必要去使用。
示例代码如下所示,创建了3个读线程,2个写线程,读线程和写线程竞争获取锁。
static void Main(string[] args)
{
// 创建3个 读线程
new Thread(() => Read("Reader 1")) { IsBackground = true }.Start();
new Thread(() => Read("Reader 2")) { IsBackground = true }.Start();
new Thread(() => Read("Reader 3")) { IsBackground = true }.Start();
// 创建两个写线程
new Thread(() => Write("Writer 1")) { IsBackground = true }.Start();
new Thread(() => Write("Writer 2")) { IsBackground = true }.Start();
// 使程序运行30S
Thread.Sleep(TimeSpan.FromSeconds(30));
Console.ReadLine();
}
static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
static Dictionary<int, int> _items = new Dictionary<int, int>();
static void Read(string threadName)
{
while (true)
{
try
{
// 获取读锁定
_rw.EnterReadLock();
Console.WriteLine($"{threadName} 从字典中读取内容 {DateTime.Now.ToString("mm:ss.ffff")}");
foreach (var key in _items.Keys)
{
Thread.Sleep(TimeSpan.FromSeconds(0.1));
}
}
finally
{
// 释放读锁定
_rw.ExitReadLock();
}
}
}
static void Write(string threadName)
{
while (true)
{
try
{
int newKey = new Random().Next(250);
// 尝试进入可升级锁模式状态
_rw.EnterUpgradeableReadLock();
if (!_items.ContainsKey(newKey))
{
try
{
// 获取写锁定
_rw.EnterWriteLock();
_items[newKey] = 1;
Console.WriteLine($"{threadName} 将新的键 {newKey} 添加进入字典中 {DateTime.Now.ToString("mm:ss.ffff")}");
}
finally
{
// 释放写锁定
_rw.ExitWriteLock();
}
}
Thread.Sleep(TimeSpan.FromSeconds(0.1));
}
finally
{
// 减少可升级模式递归计数,并在计数为0时 推出可升级模式
_rw.ExitUpgradeableReadLock();
}
}
}
运行结果如下所示,与预期结果相符。
1.10 使用SpinWait类
SpinWait
是一个常用的混合模式的类,它被设计成使用用户模式等待一段时间,人后切换至内核模式以节省CPU时间。
它的使用非常简单,演示代码如下所示。
static void Main(string[] args)
{
var t1 = new Thread(UserModeWait);
var t2 = new Thread(HybridSpinWait);
Console.WriteLine("运行在用户模式下");
t1.Start();
Thread.Sleep(20);
_isCompleted = true;
Thread.Sleep(TimeSpan.FromSeconds(1));
_isCompleted = false;
Console.WriteLine("运行在混合模式下");
t2.Start();
Thread.Sleep(5);
_isCompleted = true;
Console.ReadLine();
}
static volatile bool _isCompleted = false;
static void UserModeWait()
{
while (!_isCompleted)
{
Console.Write(".");
}
Console.WriteLine();
Console.WriteLine("等待结束");
}
static void HybridSpinWait()
{
var w = new SpinWait();
while (!_isCompleted)
{
w.SpinOnce();
Console.WriteLine(w.NextSpinWillYield);
}
Console.WriteLine("等待结束");
}
运行结果如下两图所示,首先程序运行在模拟的用户模式下,使CPU有一个短暂的峰值。然后使用SpinWait
工作在混合模式下,首先标志变量为False
处于用户模式自旋中,等待以后进入内核模式。
参考书籍
本文主要参考了以下几本书,在此对这些作者表示由衷的感谢你们提供了这么好的资料。
- 《CLR via C#》
- 《C# in Depth Third Edition》
- 《Essential C# 6.0》
- 《Multithreading with C# Cookbook Second Edition》
源码下载点击链接 示例源码下载