2

I started using a Python script for a stopwatch today and noticed a significant slow down in all the other things I have opened (Firefox, Sublime Text, Terminal). System Monitor is telling me my stopwatch script is using about 24% of my CPU. Seems odd that something so trivial uses that much resource.

Can I please get some pointers on how to improve this? I'd really like to run it in the background and keep track of my time spent on various things.

Here is the scripts:

#! /usr/bin/env python3
import tkinter
import time
import datetime
import numpy as np 
import subprocess

class StopWatch(tkinter.Frame):

    @classmethod
    def main(cls):
        tkinter.NoDefaultRoot()
        root = tkinter.Tk()
        root.title('Stop Watch')
        root.resizable(False, False)
        root.grid_columnconfigure(0, weight=1)
        root.geometry("200x235")
        padding = dict(padx=5, pady=5)
        widget = StopWatch(root, **padding)
        widget.grid(sticky=tkinter.NSEW, **padding)
        icon = tkinter.PhotoImage(file='stopwatch.ico')
        root.tk.call('wm', 'iconphoto', root._w, icon)
        root.mainloop()

    def __init__(self, master=None, cnf={}, **kw):
        padding = dict(padx=kw.pop('padx', 5), pady=kw.pop('pady', 5))
        super().__init__(master, cnf, **kw)

        self.grid_columnconfigure(0,weight=1)

        self.__total = 0
        self.start_time=datetime.datetime.now().strftime("%H:%M")
        self.start_date=datetime.datetime.now().strftime("%m/%d/%Y")
        self.start_dt=tkinter.StringVar(self, self.start_time+" "+self.start_date)

        self.__label = tkinter.Label(self, text='Session Time:')
        self.__time = tkinter.StringVar(self, '00:00')
        self.__display = tkinter.Label(self, textvariable=self.__time,font=(None, 26),height=2)
        self.__button = tkinter.Button(self, text='Start', relief=tkinter.RAISED, bg='#008000', activebackground="#329932", command=self.__click)
        self.__record = tkinter.Button(self, text='Record', relief=tkinter.RAISED, command=self.__save)
        self.__startdt = tkinter.Label(self, textvariable=self.start_dt)

        self.__label.grid   (row=0, column=0, sticky=tkinter.NSEW, **padding)
        self.__display.grid (row=1, column=0, sticky=tkinter.NSEW, **padding)
        self.__button.grid  (row=2, column=0, sticky=tkinter.NSEW, **padding)
        self.__record.grid  (row=3, column=0, sticky=tkinter.NSEW, **padding)
        self.__startdt.grid (row=4, column=0, sticky=tkinter.N, **padding)

    def __click(self):
        if self.__total==0:
            self.start_time=datetime.datetime.now().strftime("%H:%M")
            self.start_date=datetime.datetime.now().strftime("%m/%d/%Y")
            self.__time.set(self.start_time+" "+self.start_date)
        if self.__button['text'] == 'Start':
            self.__button['text'] = 'Stop'
            self.__button['bg']='#ff0000'
            self.__button['activebackground']='#ff3232'
            self.__record['text']='Record'
            self.__record['state']='disabled'
            self.__record['relief']=tkinter.SUNKEN
            self.__start = time.clock()
            self.__counter = self.after_idle(self.__update)
        else:
            self.__button['text'] = 'Start'
            self.__button['bg']='#008000'
            self.__button['activebackground']='#329932'
            self.__record['state']='normal'
            self.__record['relief']=tkinter.RAISED
            self.after_cancel(self.__counter)

    def __save(self):
        duration = int(self.__total//60)
        if duration > 0:
            subprocess.call("cp test_data.dat ./backup", shell=True)
            data = np.loadtxt('test_data.dat', dtype="str")

            time_data = data[:, 0]
            date_data = data[:, 1]
            duration_data = data[:, 2]

            time_data=np.append(time_data,self.start_time)
            date_data=np.append(date_data,self.start_date)
            duration_data=np.append(duration_data,str(duration))

            new_data=np.column_stack((time_data,date_data,duration_data))
            np.savetxt('test_data.dat', new_data, header="*Time* | *Date* | *Duration*", fmt="%s")

            self.__record['text']='Saved'
        else:
            self.__record['text']='Not Saved'

        self.start_time=datetime.datetime.now().strftime("%H:%M")
        self.start_date=datetime.datetime.now().strftime("%m/%d/%Y")
        self.__time.set(self.start_time+" "+self.start_date)
        self.__total=0
        self.__time.set('00:00')

        self.__record['state']='disabled'
        self.__record['relief']=tkinter.SUNKEN


    def __update(self):
        now = time.clock()
        diff = now - self.__start
        self.__start = now
        self.__total += diff
        mins,secs=divmod(self.__total,60)
        self.__time.set('{:02.0f}:{:02.0f}'.format(mins,secs))
        self.start_dt.set(datetime.datetime.now().strftime("%H:%M %m/%d/%Y"))
        self.__counter = self.after_idle(self.__update)

if __name__ == '__main__':
    StopWatch.main()

Minh Tran
  • 55
  • 6
  • 1
    Not sure what I am looking at, but to me it seems you are having `__update` call itself (after_idle) which is going around like crazy. It means every idle moment of the processor is claimed -> processor is occupied nearly 100%, or I must be missing something? – Jacob Vlijm May 13 '19 at 08:27
  • There are a few more issues btw. – Jacob Vlijm May 13 '19 at 08:29
  • 1
    @jacob Yes. And the difference between 100 and 24% might be a result of having 4 CPUs. – PerlDuck May 13 '19 at 08:42
  • @PerlDuck ^ this! – Jacob Vlijm May 13 '19 at 09:08
  • @jacob Yes! You are right. Im fairly new at this, and that is the only way I can think of to continuously update the clock for the stopwatch. System Monitor shows that 1 out of 4 CPUs are constantly at 100%, which results in the ~24% total. Is there anyway for me to update the clock without having the CPU committed all the time? – Minh Tran May 13 '19 at 10:10
  • Hi Minh Tran, posted. Please mention if anything is unclear. – Jacob Vlijm May 13 '19 at 11:13

2 Answers2

6

How to prevent the processor from going nuts on polling time

In your snippet:

def __update(self):
    now = time.clock()
    diff = now - self.__start
    self.__start = now
    self.__total += diff
    mins,secs=divmod(self.__total,60)
    self.__time.set('{:02.0f}:{:02.0f}'.format(mins,secs))
    self.start_dt.set(datetime.datetime.now().strftime("%H:%M %m/%d/%Y"))
    self.__counter = self.after_idle(self.__update)

You have the function rerun itself on idle without any limitation. That means your processor will spend each and every moment on idle to update the time. This will lead to a processor load of nearly 100%. Since it uses only one out of four cores, you'll see your (nearly) 25%.

Simply use a "smart", variable while loop; the principle

If we'd use time.sleep(), since we are not using real processor clock time, we would have a slight deviation. The processor always needs a little time to process the command, so

time.sleep(1)

will actually be something like

time.sleep(1.003)

This would, without further actions, lead to accumulating deviation, however:

We can make the process smart. What I always do in desktop applications is to calibrate the sleep() after each second or minute, depending on the required precision. What a cycle uses as time to process is retracted from the next cycle, so there is never an accumulation of deviation.

In principle:

import time

seconds = 0 # starttime (displayed)
startt = time.time() # real starttime
print("seconds:", seconds)

wait = 1

while True:
    time.sleep(wait)
    seconds = seconds + 1 # displayed time (seconds)
    target = startt + seconds # the targeted time
    real = time.time() # the "real" time
    calibration = real - target # now fix the difference between real and targeted
    nextwait = 1 - calibration # ...and retract that from the sleep of 1 sec
    wait = nextwait if nextwait >= 0 else 1  # prevent errors in extreme situation
    print("correction:", calibration)
    print("seconds:", seconds)

Since you are using seconds as a unit, this seems sufficient. The additional burden: unmeasureable.

Running this snippet in terminal, you'll see both the displayed time and the fixed deviation:

seconds: 0
correction: 0.02682352066040039
seconds: 1
correction: 0.036485910415649414
seconds: 2
correction: 0.06434035301208496
seconds: 3
correction: 0.07763338088989258
seconds: 4
correction: 0.037987709045410156
seconds: 5
correction: 0.03364992141723633
seconds: 6
correction: 0.07647705078125
seconds: 7

Using after() instead of while?

Likewise, you can use Tkinters after() method, as described here, using the same trick with variable time to calibrate.


EDIT

On request: example using Tkinter's after() method

If you use a fixed looptime, you are:

  1. unavoidably waisting resources, since your loop time (time resolution) needs to be a small fraction of the displayed time unit.
  2. Even if you do, like your 200 ms, the displayed time will at times show a difference with real time of (nearly) 200ms, subsequently followed by a much too short jump to the next displayed second.

If you use after(), and want to use a variable time cycle, like in the non-gui example above, below an example, offering the exact same options as the snippet in your answer:

enter image description here

#!/usr/bin/env python3
from tkinter import *
import time

class TestWhile:

    def __init__(self):

        # state on startup, run or not, initial wait etc
        self.run = False
        self.showtime = 0
        self.wait = 1000
        # window stuff
        self.window = Tk()
        shape = Canvas(width=200, height=0).grid(column=0, row=0)
        self.showtext = Label(text="00:00:00", font=(None, 26))
        self.showtext.grid(column=0, row=1)
        self.window.minsize(width=200, height=50)
        self.window.title("Test 123(4)")
        # toggle button Run/Stop
        self.togglebutton = Button(text="Start", command = self.toggle_run)
        self.togglebutton.grid(column=0, row=2, sticky=NSEW, padx=5, pady=5)
        self.resetbutton = Button(text="reset", command = self.reset)
        self.resetbutton.grid(column=0, row=3, sticky=NSEW, padx=5, pady=5)
        self.window.mainloop()

    def format_seconds(self, seconds):
        mins, secs = divmod(seconds, 60)
        hrs, mins = divmod(mins, 60)
        return '{:02d}:{:02d}:{:02d}'.format(hrs, mins, secs)

    def reset(self):
        self.showtime = 0
        self.showtext.configure(text="00:00:00")

    def toggle_run(self):
        # toggle run
        if self.run:
            self.run = False
            self.togglebutton.configure(text="Run")
            self.showtime = self.showtime - 1
            self.resetbutton.configure(state=NORMAL)
        else:
            self.run = True
            self.togglebutton.configure(text="Stop")
            self.resetbutton.configure(state=DISABLED)
            # prepare loop, set values etc
            self.showtext.configure(text=self.format_seconds(self.showtime))
            self.fix = self.showtime
            self.starttime = time.time()
            # Let's set the first cycle to one second
            self.window.after(self.wait, self.fakewhile)

    def update(self):
        self.window.after(self.wait, self.fakewhile)
        self.showtext.configure(text=str(self.format_seconds(self.showtime)))
        self.targeted_time = self.starttime + self.showtime
        self.realtime = time.time() + self.fix
        diff = self.realtime - self.targeted_time
        self.wait = int((1 - diff) * 1000)
        print("next update after:", self.wait, "ms")

    def fakewhile(self):
        self.showtime = self.showtime + 1
        if self.run:
            self.update()


TestWhile()

Note

...that if you are updating GUI from a second thread in e.g. a Gtk application, you'd always need to update from idle.

Jacob Vlijm
  • 82,471
  • 12
  • 195
  • 299
0

Thanks Jacob Vlijm for the guidance.

I tried to incorporate time.sleep() method into the previous code snippet. It didn't work at all. So I turned to tkinter after() method and re-wrote the code entirely. I'm going to leave the core of it here for whoever comes after and stumble upon this thread.

Using after() method and let script waits for 200ms before calling the function again frees up my CPU and still allows for decently smooth Stopwatch.

EDIT: remove redundant bad codes. See Jacob's comment above if you are on the same quest for working timer scripts with tkinter.

Minh Tran
  • 55
  • 6
  • 1
    You are really overcomplicating things. All you need to do is update the label with the formatted output of the loop. A fixed loop of 200 ms is still consuming 5 times as much as a smart loop and is far,far less precise. Factor 100 or so, since in my test, most of the deviation is caused by the print command in terminal. – Jacob Vlijm May 14 '19 at 10:42
  • @JacobVlijm Hey, thanks for the input to my new scripts. I'm still very new to this so any feedback helps. Your loop is very simple and I tried to use it. However, I couldnt get it to run concurrently with tkinter mainloop(). How would you incorporate that while() loop if you were to get it to show on a tk GUI? – Minh Tran May 14 '19 at 11:35
  • I'll post an example later today. – Jacob Vlijm May 14 '19 at 12:12
  • And done. Please let me know if you manage! – Jacob Vlijm May 14 '19 at 15:42